lib_mal/client.rs
1use crate::model::{
2 fields::AnimeFields,
3 options::{Params, RankingType, Season, StatusUpdate},
4 AnimeDetails, AnimeList, ForumBoards, ForumTopics, ListStatus, TopicDetails, User,
5};
6use rand::random;
7use reqwest::Client;
8use reqwest::{Method, StatusCode};
9use serde::{Deserialize, Serialize};
10#[allow(unused_imports)]
11use simple_log::{debug, info};
12use std::{fs::File, io::Write, path::PathBuf, str, time::SystemTime};
13use tiny_http::{Response, Server};
14
15use crate::MALError;
16
17use aes_gcm::aead::{Aead, NewAead};
18use aes_gcm::{Aes256Gcm, Key, Nonce};
19
20///Exposes all of the API functions for the [MyAnimeList API](https://myanimelist.net/apiconfig/references/api/v2)
21///
22///**With the exception of all the manga-related functions which haven't been implemented yet**
23///
24///# Example
25///```no_run
26/// use lib_mal::ClientBuilder;
27/// # use lib_mal::MALError;
28/// # async fn test() -> Result<(), MALError> {
29/// let client = ClientBuilder::new().secret("[YOUR_SECRET_HERE]".to_string()).build_no_refresh();
30/// //--do authorization stuff before accessing the functions--//
31///
32/// //Gets the details with all fields for Mobile Suit Gundam
33/// let anime = client.get_anime_details(80, None).await?;
34/// //You should actually handle the potential error
35/// println!("Title: {} | Started airing: {} | Finished airing: {}",
36/// anime.show.title,
37/// anime.start_date.unwrap(),
38/// anime.end_date.unwrap());
39/// # Ok(())
40/// # }
41///```
42pub struct MALClient {
43 client_secret: String,
44 dirs: PathBuf,
45 access_token: String,
46 client: reqwest::Client,
47 caching: bool,
48 pub need_auth: bool,
49}
50
51impl MALClient {
52 pub fn new(
53 client_secret: String,
54 dirs: PathBuf,
55 access_token: String,
56 client: Client,
57 caching: bool,
58 need_auth: bool,
59 ) -> Self {
60 MALClient {
61 client_secret,
62 dirs,
63 access_token,
64 caching,
65 need_auth,
66 client,
67 }
68 }
69
70 ///Creates a client using provided token. Caching is disable by default.
71 ///
72 ///A client created this way can't authenticate the user if needed because it lacks a
73 ///`client_secret`
74 pub fn with_access_token(token: &str) -> Self {
75 MALClient {
76 client_secret: String::new(),
77 need_auth: false,
78 dirs: PathBuf::new(),
79 access_token: token.to_owned(),
80 client: reqwest::Client::new(),
81 caching: false,
82 }
83 }
84
85 ///Sets the directory the client will use for the token cache
86 pub fn set_cache_dir(&mut self, dir: PathBuf) {
87 self.dirs = dir;
88 }
89
90 ///Sets wether the client will cache or not
91 pub fn set_caching(&mut self, caching: bool) {
92 self.caching = caching;
93 }
94
95 ///Returns the auth URL and code challenge which will be needed to authorize the user.
96 ///
97 ///# Example
98 ///
99 ///```no_run
100 /// use lib_mal::ClientBuilder;
101 /// # use lib_mal::MALError;
102 /// # async fn test() -> Result<(), MALError> {
103 /// let redirect_uri = "http://localhost:2525";//<-- example uri
104 /// let mut client =
105 /// ClientBuilder::new().secret("[YOUR_SECRET_HERE]".to_string()).build_no_refresh();
106 /// let (url, challenge, state) = client.get_auth_parts();
107 /// println!("Go here to log in: {}", url);
108 /// client.auth(&redirect_uri, &challenge, &state).await?;
109 /// # Ok(())
110 /// # }
111 ///```
112 pub fn get_auth_parts(&self) -> (String, String, String) {
113 let verifier = pkce::code_verifier(128);
114 let challenge = pkce::code_challenge(&verifier);
115 let state = format!("bruh{}", random::<u8>());
116 let url = format!("https://myanimelist.net/v1/oauth2/authorize?response_type=code&client_id={}&code_challenge={}&state={}", self.client_secret, challenge, state, );
117 (url, challenge, state)
118 }
119
120 ///Listens for the OAuth2 callback from MAL on `callback_url`, which is the redirect_uri
121 ///registered when obtaining the API token from MAL. Only HTTP URIs are supported right now.
122 ///
123 ///# NOTE
124 ///
125 ///For now only applications with a single registered URI are supported, having more than one
126 ///seems to cause issues with the MAL api itself
127 ///
128 ///# Example
129 ///
130 ///```no_run
131 /// use lib_mal::ClientBuilder;
132 /// # use lib_mal::MALError;
133 /// # async fn test() -> Result<(), MALError> {
134 /// let redirect_uri = "localhost:2525";//<-- example uri,
135 /// //appears as "http://localhost:2525" in the API settings
136 /// let mut client = ClientBuilder::new().secret("[YOUR_SECRET_HERE]".to_string()).build_no_refresh();
137 /// let (url, challenge, state) = client.get_auth_parts();
138 /// println!("Go here to log in: {}", url);
139 /// client.auth(&redirect_uri, &challenge, &state).await?;
140 /// # Ok(())
141 /// # }
142 ///
143 ///```
144 pub async fn auth(
145 &mut self,
146 callback_url: &str,
147 challenge: &str,
148 state: &str,
149 ) -> Result<(), MALError> {
150 let mut code = "".to_owned();
151 let url = if callback_url.contains("http") {
152 //server won't work if the url has the protocol in it
153 callback_url
154 .trim_start_matches("http://")
155 .trim_start_matches("https://")
156 } else {
157 callback_url
158 };
159
160 let server = Server::http(url).unwrap();
161 for i in server.incoming_requests() {
162 if !i.url().contains(&format!("state={}", state)) {
163 //if the state doesn't match, discard this response
164 continue;
165 }
166 let res_raw = i.url();
167 debug!("raw response: {}", res_raw);
168 code = res_raw
169 .split_once('=')
170 .unwrap()
171 .1
172 .split_once('&')
173 .unwrap()
174 .0
175 .to_owned();
176 let response = Response::from_string("You're logged in! You can now close this window");
177 i.respond(response).unwrap();
178 break;
179 }
180
181 self.get_tokens(&code, challenge).await
182 }
183
184 async fn get_tokens(&mut self, code: &str, verifier: &str) -> Result<(), MALError> {
185 let params = [
186 ("client_id", self.client_secret.as_str()),
187 ("grant_type", "authorization_code"),
188 ("code_verifier", verifier),
189 ("code", code),
190 ];
191 let rec = self
192 .client
193 .request(Method::POST, "https://myanimelist.net/v1/oauth2/token")
194 .form(¶ms)
195 .build()
196 .unwrap();
197 let res = self.client.execute(rec).await.unwrap();
198 let text = res.text().await.unwrap();
199 if let Ok(tokens) = serde_json::from_str::<TokenResponse>(&text) {
200 self.access_token = tokens.access_token.clone();
201
202 let tjson = Tokens {
203 access_token: tokens.access_token,
204 refresh_token: tokens.refresh_token,
205 expires_in: tokens.expires_in,
206 today: SystemTime::now()
207 .duration_since(SystemTime::UNIX_EPOCH)
208 .unwrap()
209 .as_secs(),
210 };
211 if self.caching {
212 let mut f =
213 File::create(self.dirs.join("tokens")).expect("Unable to create token file");
214 f.write_all(&encrypt_token(tjson))
215 .expect("Unable to write tokens");
216 }
217 Ok(())
218 } else {
219 Err(MALError::new("Unable to get tokens", "None", text))
220 }
221 }
222
223 ///Sends a get request to the specified URL with the appropriate auth header
224 async fn do_request(&self, url: String) -> Result<String, MALError> {
225 match self
226 .client
227 .get(url)
228 .bearer_auth(&self.access_token)
229 .send()
230 .await
231 {
232 Ok(res) => Ok(res.text().await.unwrap()),
233 Err(e) => Err(MALError::new(
234 "Unable to send request",
235 &format!("{}", e),
236 None,
237 )),
238 }
239 }
240
241 ///Sends a put request to the specified URL with the appropriate auth header and
242 ///form encoded parameters
243 async fn do_request_forms(
244 &self,
245 url: String,
246 params: Vec<(&str, String)>,
247 ) -> Result<String, MALError> {
248 match self
249 .client
250 .put(url)
251 .bearer_auth(&self.access_token)
252 .form(¶ms)
253 .send()
254 .await
255 {
256 Ok(res) => Ok(res.text().await.unwrap()),
257 Err(e) => Err(MALError::new(
258 "Unable to send request",
259 &format!("{}", e),
260 None,
261 )),
262 }
263 }
264
265 ///Tries to parse a JSON response string into the type provided in the `::<>` turbofish
266 fn parse_response<'a, T: Serialize + Deserialize<'a>>(
267 &self,
268 res: &'a str,
269 ) -> Result<T, MALError> {
270 match serde_json::from_str::<T>(res) {
271 Ok(v) => Ok(v),
272 Err(_) => Err(match serde_json::from_str::<MALError>(res) {
273 Ok(o) => o,
274 Err(e) => MALError::new(
275 "unable to parse response",
276 &format!("{}", e),
277 res.to_string(),
278 ),
279 }),
280 }
281 }
282
283 ///Returns the current access token. Intended mostly for debugging.
284 ///
285 ///# Example
286 ///
287 ///```no_run
288 /// # use lib_mal::ClientBuilder;
289 /// # use lib_mal::MALError;
290 /// # use std::path::PathBuf;
291 /// # async fn test() -> Result<(), MALError> {
292 /// let client = ClientBuilder::new().secret("[YOUR_SECRET_HERE]".to_string()).caching(true).cache_dir(Some(PathBuf::new())).build_with_refresh().await?;
293 /// let token = client.get_access_token();
294 /// Ok(())
295 /// # }
296 ///```
297 pub fn get_access_token(&self) -> &str {
298 &self.access_token
299 }
300
301 //Begin API functions
302
303 //--Anime functions--//
304 ///Gets a list of anime based on the query string provided
305 ///`limit` defaults to 100 if `None`
306 ///
307 ///# Example
308 ///
309 ///```no_run
310 /// # use lib_mal::MALClient;
311 /// # use lib_mal::MALError;
312 /// # async fn test() -> Result<(), MALError> {
313 /// # let client = MALClient::with_access_token("[YOUR_SECRET_HERE]");
314 /// let list = client.get_anime_list("Mobile Suit Gundam", None).await?;
315 /// # Ok(())
316 /// # }
317 ///```
318 pub async fn get_anime_list(
319 &self,
320 query: &str,
321 limit: impl Into<Option<u8>>,
322 ) -> Result<AnimeList, MALError> {
323 let url = format!(
324 "https://api.myanimelist.net/v2/anime?q={}&limit={}",
325 query,
326 limit.into().unwrap_or(100)
327 );
328 let res = self.do_request(url).await?;
329 self.parse_response(&res)
330 }
331
332 ///Gets the details for an anime by the show's ID.
333 ///Only returns the fields specified in the `fields` parameter
334 ///
335 ///Returns all fields when supplied `None`
336 ///
337 ///# Example
338 ///
339 ///```no_run
340 /// use lib_mal::model::fields::AnimeFields;
341 /// # use lib_mal::{MALError, MALClient};
342 /// # async fn test() -> Result<(), MALError> {
343 /// # let client = MALClient::with_access_token("[YOUR_SECRET_HERE]");
344 /// //returns an AnimeDetails struct with just the Rank, Mean, and Studio data for Mobile Suit Gundam
345 /// let res = client.get_anime_details(80, AnimeFields::Rank | AnimeFields::Mean | AnimeFields::Studios).await?;
346 /// # Ok(())
347 /// # }
348 ///
349 ///```
350 ///
351 pub async fn get_anime_details(
352 &self,
353 id: u32,
354 fields: impl Into<Option<AnimeFields>>,
355 ) -> Result<AnimeDetails, MALError> {
356 let url = if let Some(f) = fields.into() {
357 format!("https://api.myanimelist.net/v2/anime/{}?fields={}", id, f)
358 } else {
359 format!(
360 "https://api.myanimelist.net/v2/anime/{}?fields={}",
361 id,
362 AnimeFields::ALL
363 )
364 };
365 let res = self.do_request(url).await?;
366 self.parse_response(&res)
367 }
368
369 ///Gets a list of anime ranked by `RankingType`
370 ///
371 ///`limit` defaults to the max of 100 when `None`
372 ///
373 ///# Example
374 ///
375 ///```no_run
376 /// # use lib_mal::{MALError, MALClient};
377 /// use lib_mal::model::options::RankingType;
378 /// # async fn test() -> Result<(), MALError> {
379 /// # let client = MALClient::with_access_token("[YOUR_SECRET_HERE]");
380 /// // Gets a list of the top 5 most popular anime
381 /// let ranking_list = client.get_anime_ranking(RankingType::ByPopularity, 5).await?;
382 /// # Ok(())
383 /// # }
384 ///
385 ///```
386 pub async fn get_anime_ranking(
387 &self,
388 ranking_type: RankingType,
389 limit: impl Into<Option<u8>>,
390 ) -> Result<AnimeList, MALError> {
391 let url = format!(
392 "https://api.myanimelist.net/v2/anime/ranking?ranking_type={}&limit={}",
393 ranking_type,
394 limit.into().unwrap_or(100)
395 );
396 let res = self.do_request(url).await?;
397 Ok(serde_json::from_str(&res).unwrap())
398 }
399
400 ///Gets the anime for a given season in a given year
401 ///
402 ///`limit` defaults to the max of 100 when `None`
403 ///
404 ///# Example
405 ///
406 ///```no_run
407 /// # use lib_mal::{MALClient, MALError};
408 /// use lib_mal::model::options::Season;
409 /// # async fn test() -> Result<(), MALError> {
410 /// # let client = MALClient::with_access_token("[YOUR_SECRET_HERE]");
411 /// let summer_2019 = client.get_seasonal_anime(Season::Summer, 2019, None).await?;
412 /// # Ok(())
413 /// # }
414 ///```
415 pub async fn get_seasonal_anime(
416 &self,
417 season: Season,
418 year: u32,
419 limit: impl Into<Option<u8>>,
420 ) -> Result<AnimeList, MALError> {
421 let url = format!(
422 "https://api.myanimelist.net/v2/anime/season/{}/{}?limit={}",
423 year,
424 season,
425 limit.into().unwrap_or(100)
426 );
427 let res = self.do_request(url).await?;
428 self.parse_response(&res)
429 }
430
431 ///Returns the suggested anime for the current user. Can return an empty list if the user has
432 ///no suggestions.
433 ///
434 ///# Example
435 ///
436 ///```no_run
437 /// # use lib_mal::{MALClient, MALError};
438 /// # async fn test() -> Result<(), MALError> {
439 /// # let client = MALClient::with_access_token("[YOUR_SECRET_HERE]");
440 /// let suggestions = client.get_suggested_anime(10).await?;
441 /// # Ok(())
442 /// # }
443 ///```
444 pub async fn get_suggested_anime(
445 &self,
446 limit: impl Into<Option<u8>>,
447 ) -> Result<AnimeList, MALError> {
448 let url = format!(
449 "https://api.myanimelist.net/v2/anime/suggestions?limit={}",
450 limit.into().unwrap_or(100)
451 );
452 let res = self.do_request(url).await?;
453 self.parse_response(&res)
454 }
455
456 //--User anime list functions--//
457
458 ///Adds an anime to the list, or updates the element if it already exists
459 ///
460 ///# Example
461 ///
462 ///```no_run
463 /// # use lib_mal::{MALClient, MALError};
464 /// use lib_mal::model::StatusBuilder;
465 /// use lib_mal::model::options::Status;
466 /// # async fn test() -> Result<(), MALError> {
467 /// # let client = MALClient::with_access_token("[YOUR_SECRET_HERE]");
468 /// // add a new anime to the user's list
469 /// let updated_status = client.update_user_anime_status(80, StatusBuilder::new().status(Status::Watching).build()).await?;
470 /// // or update an existing one
471 /// let new_status = StatusBuilder::new().status(Status::Dropped).num_watched_episodes(2).build();
472 /// let updated_status = client.update_user_anime_status(32981, new_status).await?;
473 ///
474 /// # Ok(())
475 ///
476 /// # }
477 ///```
478 pub async fn update_user_anime_status(
479 &self,
480 id: u32,
481 update: StatusUpdate,
482 ) -> Result<ListStatus, MALError> {
483 let params = update.get_params();
484 let url = format!("https://api.myanimelist.net/v2/anime/{}/my_list_status", id);
485 let res = self.do_request_forms(url, params).await?;
486 self.parse_response(&res)
487 }
488
489 ///Returns the user's full anime list as an `AnimeList` struct.
490 ///
491 ///# Example
492 ///
493 ///```no_run
494 /// # use lib_mal::{MALClient, MALError};
495 /// # async fn test() -> Result<(), MALError> {
496 /// # let client = MALClient::with_access_token("[YOUR_SECRET_HERE]");
497 /// let my_list = client.get_user_anime_list().await?;
498 /// # Ok(())
499 ///
500 /// # }
501 ///```
502 pub async fn get_user_anime_list(&self) -> Result<AnimeList, MALError> {
503 let url = "https://api.myanimelist.net/v2/users/@me/animelist?fields=list_status&limit=4";
504 let res = self.do_request(url.to_owned()).await?;
505
506 self.parse_response(&res)
507 }
508
509 ///Deletes the anime with `id` from the user's anime list
510 ///
511 ///# Note
512 /// The [API docs from MAL](https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_my_list_status_delete) say this method should return 404 if the anime isn't in the user's
513 /// list, but in my testing this wasn't true. Without that there's no way to tell if the item
514 /// was actually deleted or not.
515 ///
516 ///# Example
517 ///
518 ///```no_run
519 /// # use lib_mal::{MALClient, MALError};
520 /// # async fn test() -> Result<(), MALError> {
521 /// # let client = MALClient::with_access_token("[YOUR_SECRET_HERE]");
522 /// client.delete_anime_list_item(80).await?;
523 /// # Ok(())
524 /// # }
525 ///```
526 pub async fn delete_anime_list_item(&self, id: u32) -> Result<(), MALError> {
527 let url = format!("https://api.myanimelist.net/v2/anime/{}/my_list_status", id);
528 let res = self
529 .client
530 .delete(url)
531 .bearer_auth(&self.access_token)
532 .send()
533 .await;
534 match res {
535 Ok(r) => {
536 if r.status() == StatusCode::NOT_FOUND {
537 Err(MALError::new(
538 &format!("Anime {} not found", id),
539 r.status().as_str(),
540 None,
541 ))
542 } else {
543 Ok(())
544 }
545 }
546 Err(e) => Err(MALError::new(
547 "Unable to send request",
548 &format!("{}", e),
549 None,
550 )),
551 }
552 }
553
554 //--Forum functions--//
555
556 ///Returns a vector of `HashMap`s that represent all the forum boards on MAL
557 pub async fn get_forum_boards(&self) -> Result<ForumBoards, MALError> {
558 let res = self
559 .do_request("https://api.myanimelist.net/v2/forum/boards".to_owned())
560 .await?;
561 self.parse_response(&res)
562 }
563
564 ///Returns details of the specified topic
565 pub async fn get_forum_topic_detail(
566 &self,
567 topic_id: u32,
568 limit: impl Into<Option<u8>>,
569 ) -> Result<TopicDetails, MALError> {
570 let url = format!(
571 "https://api.myanimelist.net/v2/forum/topic/{}?limit={}",
572 topic_id,
573 limit.into().unwrap_or(100)
574 );
575 let res = self.do_request(url).await?;
576 self.parse_response(&res)
577 }
578
579 ///Returns all topics for a given query
580 pub async fn get_forum_topics(
581 &self,
582 board_id: impl Into<Option<u32>>,
583 subboard_id: impl Into<Option<u32>>,
584 query: impl Into<Option<String>>,
585 topic_user_name: impl Into<Option<String>>,
586 user_name: impl Into<Option<String>>,
587 limit: impl Into<Option<u32>>,
588 ) -> Result<ForumTopics, MALError> {
589 let params = {
590 let mut tmp = vec![];
591 if let Some(bid) = board_id.into() {
592 tmp.push(format!("board_id={}", bid));
593 }
594 if let Some(bid) = subboard_id.into() {
595 tmp.push(format!("subboard_id={}", bid));
596 }
597 if let Some(bid) = query.into() {
598 tmp.push(format!("q={}", bid));
599 }
600 if let Some(bid) = topic_user_name.into() {
601 tmp.push(format!("topic_user_name={}", bid));
602 }
603 if let Some(bid) = user_name.into() {
604 tmp.push(format!("user_name={}", bid));
605 }
606 tmp.push(format!("limit={}", limit.into().unwrap_or(100)));
607 tmp.join(",")
608 };
609 let url = format!("https://api.myanimelist.net/v2/forum/topics?{}", params);
610 let res = self.do_request(url).await?;
611 self.parse_response(&res)
612 }
613
614 ///Gets the details for the current user
615 ///
616 ///# Example
617 ///
618 ///```no_run
619 /// # use lib_mal::{MALClient, MALError};
620 /// # async fn test() -> Result<(), MALError> {
621 /// # let client = MALClient::with_access_token("[YOUR_SECRET_HERE]");
622 /// let me = client.get_my_user_info().await?;
623 /// # Ok(())
624 /// # }
625 ///```
626 pub async fn get_my_user_info(&self) -> Result<User, MALError> {
627 let url = "https://api.myanimelist.net/v2/users/@me?fields=anime_statistics";
628 let res = self.do_request(url.to_owned()).await?;
629 self.parse_response(&res)
630 }
631}
632
633#[derive(Deserialize)]
634pub(crate) struct TokenResponse {
635 pub _token_type: String,
636 pub expires_in: u32,
637 pub access_token: String,
638 pub refresh_token: String,
639}
640
641#[derive(Serialize, Deserialize)]
642pub(crate) struct Tokens {
643 pub access_token: String,
644 pub refresh_token: String,
645 pub expires_in: u32,
646 pub today: u64,
647}
648
649pub(crate) fn encrypt_token(toks: Tokens) -> Vec<u8> {
650 let key = Key::from_slice(b"one two three four five six seve");
651 let cypher = Aes256Gcm::new(key);
652 let nonce = Nonce::from_slice(b"but the eart");
653 let plain = serde_json::to_vec(&toks).unwrap();
654 let res = cypher.encrypt(nonce, plain.as_ref()).unwrap();
655 res
656}
657
658pub(crate) fn decrypt_tokens(raw: &[u8]) -> Result<Tokens, MALError> {
659 let key = Key::from_slice(b"one two three four five six seve");
660 let cypher = Aes256Gcm::new(key);
661 let nonce = Nonce::from_slice(b"but the eart");
662 match cypher.decrypt(nonce, raw.as_ref()) {
663 Ok(plain) => {
664 let text = String::from_utf8(plain).unwrap();
665 Ok(serde_json::from_str(&text).expect("couldn't parse decrypted tokens"))
666 }
667 Err(e) => Err(MALError::new(
668 "Unable to decrypt encrypted tokens",
669 &format!("{}", e),
670 None,
671 )),
672 }
673}