termusiclib/
invidious.rs

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