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(&params)
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(&params)
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}