changelog shortlog graph tags branches changeset files revisions annotate raw help

Mercurial > core / rust/lib/tenex/models/freesound/lib.rs

changeset 698: 96958d3eb5b0
parent: 3d78bed56188
author: Richard Westhaver <ellis@rwest.io>
date: Fri, 04 Oct 2024 22:04:59 -0400
permissions: -rw-r--r--
description: fixes
1 //! freesound -- freesound.org API client
2 //!
3 //! Freesound.org is a collaborative database of Creative Commons
4 //! Licensed sounds. It has a growing library of audio uploaded by
5 //! people around the world. It also provides an intricate API with
6 //! features such as basic search, upload, download, and even
7 //! fingerprint search based on analysis files from Essentia.
8 //!
9 //! This module implements the client-side freesound.org API.
10 //!
11 //! REF: <https://freesound.org/docs/api/>
12 //! ENDPOINT: <https://freesound.org/apiv2/>
13 use reqwest::{Client, IntoUrl, RequestBuilder, Response, Url};
14 use serde::{Deserialize, Serialize};
15 use std::{
16  cmp::min,
17  fmt, fs,
18  path::Path,
19  time::{Duration, SystemTime},
20 };
21 use tenex_util::{
22  indicatif::{ProgressBar, ProgressStyle},
23  oauth2::{
24  basic::{BasicClient, BasicTokenType},
25  AuthUrl, AuthorizationCode, ClientId, ClientSecret, EmptyExtraTokenFields,
26  RedirectUrl, RefreshToken, StandardTokenResponse, TokenResponse, TokenUrl,
27  },
28  open_browser, StreamExt,
29 };
30 use tokio::{
31  fs::File,
32  io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
33  net::TcpListener,
34 };
35 
36 pub const FREESOUND_ENDPOINT: &str = "https://freesound.org/apiv2";
37 
38 pub const USER_AGENT: &str =
39  concat!("tenex/", env!("CARGO_PKG_VERSION"), " (https://rwest.io)");
40 
41 pub const CONFIG_FILE: &str = "freesound.json";
42 pub type Result<T> = std::result::Result<T, Error>;
43 
44 #[derive(Debug)]
45 pub enum Error {
46  Http(reqwest::Error),
47  Json(serde_json::Error),
48  Io(std::io::Error),
49  TokenExpired,
50  TokenRefreshFailed,
51 }
52 
53 impl std::error::Error for Error {
54  fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
55  match *self {
56  Error::Http(ref err) => Some(err),
57  Error::Json(ref err) => Some(err),
58  Error::Io(ref err) => Some(err),
59  Error::TokenExpired => None,
60  Error::TokenRefreshFailed => None,
61  }
62  }
63 }
64 
65 impl fmt::Display for Error {
66  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
67  match *self {
68  Error::Http(ref err) => err.fmt(f),
69  Error::Json(ref err) => err.fmt(f),
70  Error::Io(ref err) => err.fmt(f),
71  Error::TokenExpired => f.write_str("Refresh token has expired"),
72  Error::TokenRefreshFailed => {
73  f.write_str("Failed to renew auth with refresh token")
74  }
75  }
76  }
77 }
78 
79 impl From<reqwest::Error> for Error {
80  fn from(err: reqwest::Error) -> Error {
81  Error::Http(err)
82  }
83 }
84 
85 impl From<serde_json::Error> for Error {
86  fn from(err: serde_json::Error) -> Error {
87  Error::Json(err)
88  }
89 }
90 
91 impl From<std::io::Error> for Error {
92  fn from(err: std::io::Error) -> Error {
93  Error::Io(err)
94  }
95 }
96 
97 #[derive(Serialize, Deserialize, Clone, Debug, Default)]
98 pub struct FreesoundConfig {
99  pub client_id: Option<String>,
100  pub client_secret: Option<String>,
101  pub redirect_url: Option<String>,
102  pub access_token: Option<String>,
103  pub refresh_token: Option<String>,
104  pub scopes: Option<Vec<String>>,
105  pub expires: Option<u64>,
106 }
107 
108 impl FreesoundConfig {
109  pub fn update(
110  &mut self,
111  access_token: &str,
112  refresh_token: &str,
113  expires_in: Duration,
114  scopes: &[String],
115  ) {
116  self.access_token = Some(access_token.to_string());
117  self.refresh_token = Some(refresh_token.to_string());
118  self.scopes = Some(scopes.to_vec());
119  let expires = SystemTime::now()
120  .duration_since(SystemTime::UNIX_EPOCH)
121  .expect("SystemTime is before UNIX_EPOCH!?")
122  + expires_in;
123  self.expires = Some(expires.as_secs());
124  }
125  pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
126  let content = fs::read(path)?;
127  let config: FreesoundConfig = serde_json::from_slice(&content)?;
128  Ok(config)
129  }
130 }
131 
132 pub async fn write_sound<P: AsRef<Path>>(
133  res: Response,
134  dst: P,
135  progress: bool,
136 ) -> Result<()> {
137  let size = res.content_length().unwrap();
138  let mut dst = File::create(dst.as_ref()).await.unwrap();
139  let mut downloaded: u64 = 0;
140  let pb = if progress {
141  Some(
142  ProgressBar::new(size)
143  .with_style(ProgressStyle::default_spinner().template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})").unwrap()
144  .progress_chars("#>-"))
145  )
146  } else {
147  None
148  };
149 
150  let mut stream = res.bytes_stream();
151  while let Some(b) = stream.next().await {
152  let chunk = b.unwrap();
153  dst.write_all(&chunk).await.unwrap();
154  let new = min(downloaded + (chunk.len() as u64), size);
155  downloaded = new;
156  if let Some(pb) = &pb {
157  pb.set_position(new);
158  }
159  }
160  dst.flush().await.unwrap();
161  if let Some(pb) = &pb {
162  pb.finish();
163  }
164  Ok(())
165 }
166 
167 #[derive(Debug, Default)]
168 pub struct FreeSoundClient {
169  pub client: Client,
170  pub cfg: FreesoundConfig,
171 }
172 
173 impl FreeSoundClient {
174  /// Create a new FreeSoundClient.
175  ///
176  /// Note: freesound.org is an authenticated API and thus requires
177  /// the CFG field to be populated. Calls to the API will fail if
178  /// required CFG fields aren't updated. Prefer `new_with_config`
179  /// method for initialization.
180  pub fn new() -> FreeSoundClient {
181  FreeSoundClient {
182  client: Client::new(),
183  cfg: FreesoundConfig {
184  redirect_url: Some("http://localhost:8080".to_string()),
185  ..Default::default()
186  },
187  }
188  }
189 
190  /// Create a new FreeSoundClient with the given CFG.
191  pub fn new_with_config(cfg: &FreesoundConfig) -> Self {
192  FreeSoundClient {
193  client: Client::new(),
194  cfg: cfg.to_owned(),
195  }
196  }
197 
198  /// Update the net.freesound fields of a GlobalConfig.
199  pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
200  let json = serde_json::to_string_pretty(&self.cfg)?;
201  let mut path = path.as_ref().to_path_buf();
202  if path.is_dir() {
203  path = path.join(CONFIG_FILE);
204  }
205  fs::write(path, json)?;
206  Ok(())
207  }
208 
209  pub fn auth_client(&self) -> BasicClient {
210  let client_id = ClientId::new(self.cfg.client_id.as_ref().unwrap().clone());
211  let client_secret =
212  ClientSecret::new(self.cfg.client_secret.as_ref().unwrap().clone());
213  let auth_url = AuthUrl::new(format!(
214  "{}/oauth2/authorize/?client_id={}&response_type=code",
215  FREESOUND_ENDPOINT,
216  self.cfg.client_id.as_ref().unwrap().clone()
217  ))
218  .unwrap();
219  let token_url =
220  TokenUrl::new(format!("{}/oauth2/access_token/", FREESOUND_ENDPOINT))
221  .unwrap();
222  BasicClient::new(client_id, Some(client_secret), auth_url, Some(token_url))
223  .set_redirect_uri(
224  RedirectUrl::new(self.cfg.redirect_url.as_ref().unwrap().clone())
225  .unwrap(),
226  )
227  }
228 
229  pub fn update_cfg(
230  &mut self,
231  token: StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>,
232  ) {
233  self.cfg.update(
234  token.access_token().secret().as_str(),
235  token.refresh_token().unwrap().secret().as_str(),
236  token.expires_in().unwrap(),
237  &token
238  .scopes()
239  .unwrap()
240  .iter()
241  .map(|s| s.to_string())
242  .collect::<Vec<String>>(),
243  );
244  }
245 
246  /// Do the Oauth2 Dance as described in
247  /// https://freesound.org/docs/api/authentication.html#oauth2-authentication
248  ///
249  /// Step 1: user is redirected to a Freesound page where they log in
250  /// and are asked to give permission to MPK.
251  ///
252  /// Step 2: If user grants access, Freesound redirects to the
253  /// REDIRECT_URL with an authorization grant as a GET parameter.
254  ///
255  /// Step 3: MPK uses that authorization grant to request an access
256  /// token that 'links' user with MPK and that needs to be added to
257  /// all API requests.
258  ///
259  /// Note: all requests using OAuth2 API need to be made over HTTPS.
260  pub async fn auth(&mut self, auto: bool) -> Result<()> {
261  if self.refresh_auth().await.is_ok() {
262  println!("token refresh successful");
263  Ok(())
264  } else {
265  let client = self.auth_client();
266  let client_id = self.cfg.client_id.as_ref().unwrap();
267  println!(
268  "go to: {}/oauth2/authorize/?client_id={}&response_type=code",
269  FREESOUND_ENDPOINT, client_id
270  );
271  if auto {
272  open_browser(
273  format!(
274  "{}/oauth2/authorize/?client_id={}&response_type=code",
275  FREESOUND_ENDPOINT, client_id
276  )
277  .as_str(),
278  );
279  }
280  let listener = TcpListener::bind("localhost:8080").await.unwrap();
281  loop {
282  if let Ok((mut stream, _)) = listener.accept().await {
283  let mut reader = BufReader::new(&mut stream);
284  let mut line = String::new();
285  reader.read_line(&mut line).await.unwrap();
286  let redirect_url = line.split_whitespace().nth(1).unwrap();
287  let url =
288  Url::parse(&("http://localhost:8080".to_string() + redirect_url))
289  .unwrap();
290 
291  let code_pair = url
292  .query_pairs()
293  .find(|pair| {
294  let (key, _) = pair;
295  key == "code"
296  })
297  .unwrap();
298 
299  let (_, value) = code_pair;
300  let code = AuthorizationCode::new(value.into_owned());
301  println!("got code: {:?}", code.secret());
302  // let state_pair = url
303  // .query_pairs()
304  // .find(|pair| {
305  // let &(ref key, _) = pair;
306  // key == "state"
307  // })
308  // .unwrap();
309 
310  // let (_, value) = state_pair;
311  // state = CsrfToken::new(value.into_owned());
312  let message = "Go back to your terminal :)";
313  let response = format!(
314  "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}",
315  message.len(),
316  message
317  );
318  stream.write_all(response.as_bytes()).await.unwrap();
319  let token_res = client
320  .exchange_code(code)
321  .request_async(tenex_util::oauth2::reqwest::async_http_client)
322  .await
323  .unwrap();
324 
325  self.update_cfg(token_res);
326  break Ok(());
327  }
328  }
329  }
330  }
331 
332  pub async fn refresh_auth(&mut self) -> Result<()> {
333  if let Some(d) = self.cfg.expires {
334  let exp = SystemTime::now()
335  .duration_since(SystemTime::UNIX_EPOCH)
336  .expect("SystemTime is before UNIX_EPOCH!?")
337  .as_secs();
338  if exp < d {
339  let client = self.auth_client();
340  if let Some(t) = &self.cfg.refresh_token {
341  let token_res = client
342  .exchange_refresh_token(&RefreshToken::new(t.to_string()))
343  .request_async(tenex_util::oauth2::reqwest::async_http_client)
344  .await
345  .unwrap();
346  self.update_cfg(token_res);
347  return Ok(());
348  } else {
349  return Err(Error::TokenRefreshFailed);
350  }
351  } else {
352  return Err(Error::TokenExpired);
353  }
354  }
355  Ok(())
356  }
357 
358  pub async fn request<'a>(
359  &self,
360  req: FreeSoundRequest<'a>,
361  ) -> Result<Response> {
362  let res = self
363  .client
364  .execute(
365  req
366  .request(&self.client)
367  .bearer_auth(self.cfg.access_token.as_ref().unwrap())
368  .build()?,
369  )
370  .await?;
371  Ok(res)
372  }
373 
374  pub async fn get_raw<U: IntoUrl>(&self, url: U) -> Result<Response> {
375  let res = self
376  .client
377  .get(url)
378  .bearer_auth(self.cfg.access_token.as_ref().unwrap())
379  .send()
380  .await?;
381  Ok(res)
382  }
383 }
384 
385 pub enum FreeSoundRequest<'a> {
386  SearchText {
387  query: &'a str,
388  filter: Option<&'a str>,
389  sort: &'a str,
390  group_by_pack: bool,
391  weights: &'a str,
392  page: usize,
393  /// max = 150
394  page_size: u8,
395  fields: &'a [&'a str],
396  descriptors: &'a [&'a str],
397  normalized: bool,
398  },
399  SearchContent,
400  SearchCombined,
401  Sound {
402  id: u64,
403  },
404  SoundAnalysis {
405  id: u64,
406  },
407  SoundSimilar {
408  id: u64,
409  },
410  SoundComments {
411  id: u64,
412  },
413  SoundDownload {
414  id: u64,
415  },
416  SoundUpload,
417  SoundDescribe,
418  SoundPendingUpload,
419  SoundEdit,
420  SoundBookmark {
421  id: u64,
422  name: &'a str,
423  category: &'a str,
424  },
425  SoundRate,
426  SoundComment,
427  User,
428  UserSounds,
429  UserPacks,
430  UserBookmarkCategories,
431  UserBookmarkCategorySounds,
432  Pack,
433  PackSounds,
434  PackDownload,
435  Me,
436  Descriptors,
437 }
438 
439 impl<'a> FreeSoundRequest<'a> {
440  pub fn request(&self, client: &Client) -> RequestBuilder {
441  client.get(self.addr()).query(self.params().as_slice())
442  }
443 
444  pub fn addr(&self) -> String {
445  let slug = match self {
446  FreeSoundRequest::SearchText { .. } => "/search/text".to_string(),
447  FreeSoundRequest::SearchContent => "/search/content".to_string(),
448  FreeSoundRequest::SearchCombined => "/search/combined".to_string(),
449  FreeSoundRequest::Sound { ref id } => format!("/sounds/{}", id),
450  FreeSoundRequest::SoundAnalysis { ref id } => {
451  format!("/sounds/{}/analysis", id)
452  }
453  FreeSoundRequest::SoundSimilar { ref id } => {
454  format!("/sounds/{}/similar", id)
455  }
456  FreeSoundRequest::SoundComments { ref id } => {
457  format!("/sounds/{}/comments", id)
458  }
459  FreeSoundRequest::SoundDownload { ref id } => {
460  format!("/sounds/{}/download", id)
461  }
462  _ => "".to_string(),
463  };
464  format!("{}{}", FREESOUND_ENDPOINT, slug)
465  }
466 
467  pub fn params(&self) -> Vec<(String, String)> {
468  match self {
469  FreeSoundRequest::SearchText {
470  query,
471  filter,
472  sort,
473  group_by_pack,
474  weights,
475  page,
476  page_size,
477  fields,
478  descriptors,
479  normalized,
480  } => {
481  let gbp = if *group_by_pack { "1" } else { "0" }.to_string();
482  let normalized = if *normalized { "1" } else { "0" }.to_string();
483  let mut params = vec![
484  ("query".to_string(), query.to_string()),
485  ("sort".to_string(), sort.to_string()),
486  ("group_by_pack".to_string(), gbp),
487  ("normalized".to_string(), normalized),
488  ("weights".to_string(), weights.to_string()),
489  ("fields".to_string(), fields.join(",")),
490  ("descriptors".to_string(), descriptors.join(",")),
491  ("page".to_string(), page.to_string()),
492  ("page_size".to_string(), page_size.to_string()),
493  ];
494  if let Some(f) = filter {
495  params.push(("filter".to_string(), f.to_string()));
496  }
497  params
498  }
499  _ => {
500  vec![]
501  }
502  }
503  }
504 }
505 
506 #[derive(Deserialize, Debug)]
507 #[serde(untagged)]
508 pub enum FreeSoundResponse {
509  SearchText {
510  count: usize,
511  next: Option<Url>,
512  results: Vec<FreeSoundSearchResult>,
513  previous: Option<Url>,
514  },
515 }
516 
517 impl FreeSoundResponse {
518  pub async fn parse(res: Response) -> FreeSoundResponse {
519  res.json::<FreeSoundResponse>().await.unwrap()
520  }
521 }
522 
523 impl fmt::Display for FreeSoundResponse {
524  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
525  match self {
526  FreeSoundResponse::SearchText {
527  count,
528  next,
529  results,
530  previous,
531  } => {
532  let next = next
533  .as_ref()
534  .map(|u| u.to_string())
535  .unwrap_or_else(|| "null".to_string());
536  let previous = previous
537  .as_ref()
538  .map(|u| u.to_string())
539  .unwrap_or_else(|| "null".to_string());
540  let res: String = results
541  .iter()
542  .map(|r| r.to_string())
543  .collect::<Vec<String>>()
544  .join("\n");
545  f.write_str(
546  format!(
547  "count: {}, results: [\n{}\n], next: {}, previous: {}",
548  count, res, next, previous
549  )
550  .as_str(),
551  )
552  }
553  }
554  }
555 }
556 
557 #[derive(Deserialize, Debug)]
558 pub struct FreeSoundSearchResult {
559  pub id: Option<u64>,
560  pub name: Option<String>,
561  pub tags: Option<Vec<String>>,
562  pub license: Option<Url>,
563 }
564 
565 impl fmt::Display for FreeSoundSearchResult {
566  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
567  if let Some(n) = &self.id {
568  write!(f, "{}, ", n)?
569  };
570  if let Some(s) = &self.name {
571  write!(f, "name: {} ", s)?
572  };
573  if let Some(v) = &self.tags {
574  write!(f, "tags: {}, ", v.join(":"))?
575  };
576  if let Some(s) = &self.license {
577  write!(f, "license: {}, ", s)?
578  };
579  Ok(())
580  }
581 }