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 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 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 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 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 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 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 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 if chrono::Utc::now().timestamp() < config.mal_token_expires_in.unwrap() {
160 return None;
161 }
162
163 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 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 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 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 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 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 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 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 let mut num_episodes = 0;
261 if let Some(num_eps) = json["num_episodes"].as_u64() {
262 num_episodes = num_eps;
263 }
264
265 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}