plex_mal_scrobbler/
mal_api.rs

1use crate::{Config, CONFIG_PATH};
2use dialoguer::Input;
3use rand::{distr::Alphanumeric, Rng};
4use reqwest::blocking::Client as BlockingClient;
5use reqwest::Client as AsyncClient;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::fs::File;
9use std::io::Write;
10
11#[derive(Serialize, Deserialize, Debug)]
12pub struct Anime {
13    pub id: u64,
14    pub titles: Vec<String>,
15    pub total_episodes: u64,
16    pub watched_episodes: u64,
17}
18
19pub fn authorize_user(config: &Config) {
20    // This will be run first time only to get access token
21    // Generate user authorization page URL
22    let code_challenge: String = rand::rng()
23        .sample_iter(&Alphanumeric)
24        .take(128)
25        .map(char::from)
26        .collect();
27    let url = format!("https://myanimelist.net/v1/oauth2/authorize?response_type=code&client_id={}&code_challenge={}", config.mal_client_id, code_challenge);
28    println!("Please visit this site: {}", url);
29
30    println!("And after authorizing, copy the `code` URL parameter here");
31    let code: String = Input::new()
32        .with_prompt("code")
33        .report(false)
34        .interact_text()
35        .unwrap();
36
37    // Get the user's access token
38    let client = BlockingClient::new();
39    let body = client
40        .post("https://myanimelist.net/v1/oauth2/token")
41        .form(&[
42            ("client_id", config.mal_client_id.as_str()),
43            ("code", code.as_str()),
44            ("code_verifier", code_challenge.as_str()),
45            ("grant_type", "authorization_code"),
46        ])
47        .send()
48        .unwrap()
49        .text()
50        .unwrap();
51    let json: Value = serde_json::from_str(&body).unwrap();
52
53    // Generate new config with the tokens
54    let new_config = Config {
55        mal_client_id: config.mal_client_id.to_owned(),
56        mal_access_token: Some(json["access_token"].to_string().replace('"', "")),
57        mal_refresh_token: Some(json["refresh_token"].to_string().replace('"', "")),
58        mal_token_expires_in: Some(
59            chrono::Utc::now().timestamp() + json["expires_in"].as_i64().unwrap(),
60        ),
61        plex_users: config.plex_users.to_owned(),
62        plex_libraries: config.plex_libraries.to_owned(),
63        scrobble_last_ep: config.scrobble_last_ep.to_owned(),
64        require_match: config.require_match.to_owned(),
65        test: config.test.to_owned(),
66        port: config.port.to_owned(),
67    };
68    let yaml = serde_yml::to_string(&new_config).unwrap();
69
70    // Overwrite the file with new config
71    let mut new_file = File::create(CONFIG_PATH.as_str()).unwrap();
72    new_file.write_all(yaml.as_bytes()).unwrap();
73    new_file.flush().unwrap();
74}
75
76pub async fn get_user_anime_list(config: &Config) -> Vec<Anime> {
77    // Get currently watching anime sorted by last update
78    let client = AsyncClient::new();
79    let body = client
80        .get("https://api.myanimelist.net/v2/users/@me/animelist")
81        .bearer_auth(config.mal_access_token.as_deref().unwrap())
82        .query(&[
83            ("nsfw", "true"),
84            ("status", "watching"),
85            ("sort", "list_updated_at"),
86        ])
87        .send()
88        .await
89        .unwrap()
90        .text()
91        .await
92        .unwrap();
93    let json: Value = serde_json::from_str(&body).unwrap();
94
95    // Parse the json to Anime vec
96    let mut anime_list: Vec<Anime> = Vec::new();
97    for o in json["data"].as_array().unwrap().iter() {
98        let id = o["node"]["id"].as_u64().unwrap();
99        let (tts, total_eps, watched_eps) = get_anime_details(config, &id).await;
100        anime_list.push(Anime {
101            id,
102            titles: tts,
103            total_episodes: total_eps,
104            watched_episodes: watched_eps,
105        });
106    }
107    anime_list
108}
109
110pub async fn update_anime_details(
111    config: &Config,
112    anime_id: &u64,
113    total_anime_ep: &u64,
114    anime_ep: &u64,
115) {
116    let anime_episodes = anime_ep.to_string();
117    let mut form: Vec<(&str, &str)> = vec![("num_watched_episodes", anime_episodes.as_str())];
118
119    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
120
121    if total_anime_ep == anime_ep {
122        form.push(("status", "completed"));
123        form.push(("finish_date", today.as_str()));
124    }
125
126    if anime_ep == &1 {
127        form.push(("start_date", today.as_str()));
128    }
129
130    if config.test {
131        // Testing flag enabled, just print the info that would have been updated
132        println!("Test flag enabled, skipping update.");
133
134        println!("Anime: https://myanimelist.net/anime/{}/", anime_id);
135
136        println!("Form data:");
137        for item in &form {
138            println!("- {}: {}", item.0, item.1);
139        }
140    } else if AsyncClient::new()
141        .patch(format!(
142            "https://api.myanimelist.net/v2/anime/{}/my_list_status",
143            anime_id
144        ))
145        .bearer_auth(config.mal_access_token.as_deref().unwrap())
146        .form(&form)
147        .send()
148        .await
149        .is_err()
150    {
151        eprintln!("MAL API call failed");
152    }
153}
154
155pub async fn check_and_refresh_access_token(config: &Config) -> Option<Config> {
156    // Needs to be run approx every 30days
157    // Or if you get 401 from the API
158
159    if chrono::Utc::now().timestamp() < config.mal_token_expires_in.unwrap() {
160        return None;
161    }
162
163    // Refresh the access token using refresh token
164    let client = AsyncClient::new();
165    let body = client
166        .post("https://myanimelist.net/v1/oauth2/token")
167        .bearer_auth(config.mal_access_token.as_deref().unwrap())
168        .form(&[
169            ("client_id", config.mal_client_id.as_str()),
170            ("grant_type", "refresh_token"),
171            (
172                "refresh_token",
173                config.mal_refresh_token.as_deref().unwrap(),
174            ),
175        ])
176        .send()
177        .await
178        .unwrap()
179        .text()
180        .await
181        .unwrap();
182    let json: Value = serde_json::from_str(&body).unwrap();
183
184    // Generate new config with the tokens
185    let new_config = Config {
186        mal_client_id: config.mal_client_id.to_owned(),
187        mal_access_token: Some(json["access_token"].to_string().replace('"', "")),
188        mal_refresh_token: Some(json["refresh_token"].to_string().replace('"', "")),
189        mal_token_expires_in: Some(
190            chrono::Utc::now().timestamp() + json["expires_in"].as_i64().unwrap(),
191        ),
192        plex_users: config.plex_users.to_owned(),
193        plex_libraries: config.plex_libraries.to_owned(),
194        scrobble_last_ep: config.scrobble_last_ep.to_owned(),
195        require_match: config.require_match.to_owned(),
196        test: config.test.to_owned(),
197        port: config.port.to_owned(),
198    };
199    let yaml = serde_yml::to_string(&new_config).unwrap();
200
201    // Overwrite the file with new config
202    let mut new_file = File::create(CONFIG_PATH.as_str()).unwrap();
203    new_file.write_all(yaml.as_bytes()).unwrap();
204    new_file.flush().unwrap();
205
206    Some(new_config)
207}
208
209async fn get_anime_details(config: &Config, anime_id: &u64) -> (Vec<String>, u64, u64) {
210    // Get specific anime details
211    let client = AsyncClient::new();
212    let body = client
213        .get(format!("https://api.myanimelist.net/v2/anime/{}", anime_id))
214        .bearer_auth(config.mal_access_token.as_deref().unwrap())
215        .query(&[("fields", "alternative_titles, num_episodes, my_list_status")])
216        .send()
217        .await
218        .unwrap()
219        .text()
220        .await
221        .unwrap();
222    let json: Value = serde_json::from_str(&body).unwrap();
223
224    let mut titles: Vec<String> = Vec::new();
225
226    // Anime romanized title
227    if let Some(title) = json["title"].as_str() {
228        if !title.is_empty() {
229            titles.push(title.to_string().to_lowercase());
230        }
231    }
232
233    // Anime english title
234    if let Some(en) = json["alternative_titles"]["en"].as_str() {
235        if !en.is_empty() {
236            titles.push(en.to_string().to_lowercase());
237        }
238    }
239
240    // Anime japanese title
241    if let Some(ja) = json["alternative_titles"]["ja"].as_str() {
242        if !ja.is_empty() {
243            titles.push(ja.to_string().to_lowercase());
244        }
245    }
246
247    // Anime title synonyms
248    if let Some(synonyms) = json["alternative_titles"]["synonyms"].as_array() {
249        if !synonyms.is_empty() {
250            titles.push(
251                synonyms
252                    .iter()
253                    .map(|s| s.as_str().unwrap().to_string().to_lowercase())
254                    .collect(),
255            );
256        }
257    }
258
259    // Total episodes number
260    let mut num_episodes = 0;
261    if let Some(num_eps) = json["num_episodes"].as_u64() {
262        num_episodes = num_eps;
263    }
264
265    // Episodes watched number
266    let mut watched_episodes = 0;
267    if let Some(watched_eps) = json["my_list_status"]["num_episodes_watched"].as_u64() {
268        watched_episodes = watched_eps;
269    }
270
271    (titles, num_episodes, watched_episodes)
272}