termusiclib/
invidious.rs

1/**
2 * MIT License
3 *
4 * termusic - Copyright (c) 2021 Larry Hao
5 *
6 * Permission is hereby granted, free of charge, to any person obtaining a copy
7 * of this software and associated documentation files (the "Software"), to deal
8 * in the Software without restriction, including without limitation the rights
9 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 * copies of the Software, and to permit persons to whom the Software is
11 * furnished to do so, subject to the following conditions:
12 *
13 * The above copyright notice and this permission notice shall be included in all
14 * copies or substantial portions of the Software.
15 *
16 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 * SOFTWARE.
23 */
24use anyhow::{anyhow, bail, Result};
25use rand::seq::SliceRandom;
26use serde_json::Value;
27// left for debug
28// use std::io::Write;
29use reqwest::{Client, ClientBuilder, StatusCode};
30use std::time::Duration;
31
32const INVIDIOUS_INSTANCE_LIST: [&str; 5] = [
33    "https://inv.nadeko.net",
34    "https://invidious.nerdvpn.de",
35    "https://yewtu.be",
36    // "https://inv.riverside.rocks",
37    // "https://invidious.osi.kr",
38    // "https://youtube.076.ne.jp",
39    "https://y.com.sb",
40    "https://yt.artemislena.eu",
41    // "https://invidious.tiekoetter.com",
42    // Below lines are left for testing
43    // "https://www.google.com",
44    // "https://www.google.com",
45    // "https://www.google.com",
46    // "https://www.google.com",
47    // "https://www.google.com",
48    // "https://www.google.com",
49    // "https://www.google.com",
50];
51
52const INVIDIOUS_DOMAINS: &str = "https://api.invidious.io/instances.json?sort_by=type,users";
53
54#[derive(Clone, Debug)]
55pub struct Instance {
56    pub domain: Option<String>,
57    client: Client,
58    query: Option<String>,
59}
60
61impl PartialEq for Instance {
62    fn eq(&self, other: &Self) -> bool {
63        self.domain == other.domain
64    }
65}
66
67impl Eq for Instance {}
68
69#[derive(Clone, PartialEq, Eq, Debug)]
70pub struct YoutubeVideo {
71    pub title: String,
72    pub length_seconds: u64,
73    pub video_id: String,
74}
75
76impl Default for Instance {
77    fn default() -> Self {
78        let client = Client::new();
79        let domain = Some(String::new());
80        let query = Some(String::new());
81
82        Self {
83            domain,
84            client,
85            query,
86        }
87    }
88}
89
90impl Instance {
91    pub async fn new(query: &str) -> Result<(Self, Vec<YoutubeVideo>)> {
92        let client = ClientBuilder::new()
93            .timeout(Duration::from_secs(10))
94            .build()?;
95
96        let mut domain = String::new();
97        let mut domains = vec![];
98
99        // prefor fetch invidious instance from website, but will provide 7 backups
100        if let Ok(domain_list) = Self::get_invidious_instance_list(&client).await {
101            domains = domain_list;
102        } else {
103            for item in &INVIDIOUS_INSTANCE_LIST {
104                domains.push((*item).to_string());
105            }
106        }
107
108        domains.shuffle(&mut rand::thread_rng());
109
110        let mut video_result: Vec<YoutubeVideo> = Vec::new();
111        for v in domains {
112            let url = format!("{v}/api/v1/search");
113
114            let query_vec = vec![
115                ("q", query),
116                ("page", "1"),
117                ("type", "video"),
118                ("sort_by", "relevance"),
119            ];
120            if let Ok(result) = client.get(&url).query(&query_vec).send().await {
121                if result.status() == 200 {
122                    if let Ok(text) = result.text().await {
123                        if let Some(vr) = Self::parse_youtube_options(&text) {
124                            video_result = vr;
125                            domain = v;
126                            break;
127                        }
128                    }
129                }
130            }
131        }
132        if domain.len() < 2 {
133            bail!("Something is wrong with your connection or all 7 invidious servers are down.");
134        }
135
136        let domain = Some(domain);
137        Ok((
138            Self {
139                domain,
140                client,
141                query: Some(query.to_string()),
142            },
143            video_result,
144        ))
145    }
146
147    // GetSearchQuery fetches query result from an Invidious instance.
148    pub async fn get_search_query(&self, page: u32) -> Result<Vec<YoutubeVideo>> {
149        if self.domain.is_none() {
150            bail!("No server available");
151        }
152        let url = format!(
153            "{}/api/v1/search",
154            self.domain
155                .as_ref()
156                .ok_or(anyhow!("error in domain name"))?
157        );
158
159        let Some(query) = &self.query else {
160            bail!("No query string found")
161        };
162
163        let result = self
164            .client
165            .get(url)
166            .query(&[("q", query), ("page", &page.to_string())])
167            .send()
168            .await?;
169
170        match result.status() {
171            StatusCode::OK => match result.text().await {
172                Ok(text) => Self::parse_youtube_options(&text).ok_or_else(|| anyhow!("None Error")),
173                Err(e) => bail!("Error during search: {}", e),
174            },
175            _ => bail!("Error during search"),
176        }
177    }
178
179    // GetSuggestions returns video suggestions based on prefix strings. This is the
180    // same result as youtube search autocomplete.
181    pub async fn get_suggestions(&self, prefix: &str) -> Result<Vec<YoutubeVideo>> {
182        let url = format!(
183            "http://suggestqueries.google.com/complete/search?client=firefox&ds=yt&q={prefix}"
184        );
185        let result = self.client.get(url).send().await?;
186        match result.status() {
187            StatusCode::OK => match result.text().await {
188                Ok(text) => Self::parse_youtube_options(&text).ok_or_else(|| anyhow!("None Error")),
189                Err(e) => bail!("Error during search: {}", e),
190            },
191            _ => bail!("Error during search"),
192        }
193    }
194
195    // GetTrendingMusic fetch music trending based on region.
196    // Region (ISO 3166 country code) can be provided in the argument.
197    pub async fn get_trending_music(&self, region: &str) -> Result<Vec<YoutubeVideo>> {
198        if self.domain.is_none() {
199            bail!("No server available");
200        }
201        let url = format!(
202            "{}/api/v1/trending?type=music&region={region}",
203            self.domain
204                .as_ref()
205                .ok_or(anyhow!("error in domain names"))?
206        );
207
208        let result = self.client.get(url).send().await?;
209
210        match result.status() {
211            StatusCode::OK => match result.text().await {
212                Ok(text) => Self::parse_youtube_options(&text).ok_or_else(|| anyhow!("None Error")),
213                _ => bail!("Error during search"),
214            },
215            _ => bail!("Error during search"),
216        }
217    }
218
219    fn parse_youtube_options(data: &str) -> Option<Vec<YoutubeVideo>> {
220        if let Ok(value) = serde_json::from_str::<Value>(data) {
221            let mut vec: Vec<YoutubeVideo> = Vec::new();
222            // below two lines are left for debug purpose
223            // let mut file = std::fs::File::create("data.txt").expect("create failed");
224            // file.write_all(data.as_bytes()).expect("write failed");
225            if let Some(array) = value.as_array() {
226                for v in array {
227                    if let Some((title, video_id, length_seconds)) = Self::parse_youtube_item(v) {
228                        vec.push(YoutubeVideo {
229                            title,
230                            length_seconds,
231                            video_id,
232                        });
233                    }
234                }
235                return Some(vec);
236            }
237        }
238        None
239    }
240
241    fn parse_youtube_item(value: &Value) -> Option<(String, String, u64)> {
242        let title = value.get("title")?.as_str()?.to_owned();
243        let video_id = value.get("videoId")?.as_str()?.to_owned();
244        let length_seconds = value.get("lengthSeconds")?.as_u64()?;
245        Some((title, video_id, length_seconds))
246    }
247
248    async fn get_invidious_instance_list(client: &Client) -> Result<Vec<String>> {
249        let result = client.get(INVIDIOUS_DOMAINS).send().await?.text().await?;
250        // Left here for debug
251        // let mut file = std::fs::File::create("data.txt").expect("create failed");
252        // file.write_all(result.as_bytes()).expect("write failed");
253        if let Some(vec) = Self::parse_invidious_instance_list(&result) {
254            return Ok(vec);
255        }
256        bail!("no instance list fetched")
257    }
258
259    fn parse_invidious_instance_list(data: &str) -> Option<Vec<String>> {
260        if let Ok(value) = serde_json::from_str::<Value>(data) {
261            let mut vec = Vec::new();
262            if let Some(array) = value.as_array() {
263                for inner_value in array {
264                    if let Some((uri, health)) = Self::parse_instance(inner_value) {
265                        if health > 95.0 {
266                            vec.push(uri);
267                        }
268                    }
269                }
270            }
271            if !vec.is_empty() {
272                return Some(vec);
273            }
274        }
275        None
276    }
277
278    fn parse_instance(value: &Value) -> Option<(String, f64)> {
279        let obj = value.get(1)?.as_object()?;
280        if obj.get("api")?.as_bool()? {
281            let uri = obj.get("uri")?.as_str()?.to_owned();
282            let health = obj
283                .get("monitor")?
284                .as_object()?
285                .get("30dRatio")?
286                .get("ratio")?
287                .as_str()?
288                .to_owned()
289                .parse::<f64>()
290                .ok();
291            health.map(|health| (uri, health))
292        } else {
293            None
294        }
295    }
296}