repo_description: Terminal session recorder, streamer and player 📹
repo_topics: asciicast asciinema cli recording rustlang streaming terminal
code: use std::collections::HashMap;
  use std::env;
  use std::fmt::Debug;
  
  use anyhow::{bail, Context, Result};
  use reqwest::{header, Response};
  use reqwest::{multipart::Form, Client, RequestBuilder};
  use serde::de::DeserializeOwned;
  use serde::{Deserialize, Serialize};
  use url::Url;
  
  use crate::config::Config;
  
  #[derive(Debug, Deserialize)]
  pub struct RecordingResponse {
      pub url: String,
      pub message: Option<String>,
  }
  
  #[derive(Debug, Deserialize)]
  pub struct StreamResponse {
      pub id: u64,
      pub ws_producer_url: String,
      pub url: String,
  }
  
  #[derive(Serialize)]
  #[serde(rename_all = "lowercase")]
  pub enum Visibility {
      Public,
      Unlisted,
      Private,
  }
  
  #[derive(Default, Serialize)]
  pub struct RecordingChangeset {
      #[serde(skip_serializing_if = "Option::is_none")]
      pub title: Option<Option<String>>,
      #[serde(skip_serializing_if = "Option::is_none")]
      pub description: Option<Option<String>>,
      #[serde(skip_serializing_if = "Option::is_none")]
      pub visibility: Option<Visibility>,
      #[serde(skip_serializing_if = "Option::is_none")]
      pub audio_url: Option<Option<String>>,
  }
  
  #[derive(Default, Serialize)]
  pub struct StreamChangeset {
      #[serde(skip_serializing_if = "Option::is_none")]
      pub live: Option<bool>,
      #[serde(skip_serializing_if = "Option::is_none")]
      pub title: Option<Option<String>>,
      #[serde(skip_serializing_if = "Option::is_none")]
      pub description: Option<Option<String>>,
      #[serde(skip_serializing_if = "Option::is_none")]
      pub visibility: Option<Visibility>,
      #[serde(skip_serializing_if = "Option::is_none")]
      pub audio_url: Option<Option<String>>,
      #[serde(skip_serializing_if = "Option::is_none")]
      pub term_type: Option<Option<String>>,
      #[serde(skip_serializing_if = "Option::is_none")]
      pub term_version: Option<Option<String>>,
      #[serde(skip_serializing_if = "Option::is_none")]
      pub shell: Option<Option<String>>,
      #[serde(skip_serializing_if = "Option::is_none")]
      pub env: Option<Option<HashMap<String, String>>>,
  }
  
  #[derive(Debug, Deserialize)]
  struct ErrorResponse {
      message: String,
  }
  
  pub fn get_auth_url(config: &mut Config) -> Result<Url> {
      let mut url = config.get_server_url()?;
      url.set_path(&format!("connect/{}", config.get_install_id()?));
  
      Ok(url)
  }
  
  pub async fn create_recording(
      path: &str,
      changeset: RecordingChangeset,
      config: &mut Config,
  ) -> Result<RecordingResponse> {
      let server_url = &config.get_server_url()?;
      let install_id = config.get_install_id()?;
  
      let response = create_recording_request(server_url, install_id, path, changeset)
          .await?
          .send()
          .await?;
  
      if response.status().as_u16() == 413 {
          match response.json::<ErrorResponse>().await {
              Ok(json) => {
                  bail!("{}", json.message);
              }
  
              Err(_) => {
                  bail!("The recording exceeds the server-configured size limit");
              }
          }
      } else {
          response.error_for_status_ref()?;
      }
  
      Ok(response.json::<RecordingResponse>().await?)
  }
  
  async fn create_recording_request(
      server_url: &Url,
      install_id: String,
      path: &str,
      changeset: RecordingChangeset,
  ) -> Result<RequestBuilder> {
      let client = Client::new();
      let mut url = server_url.clone();
      url.set_path("api/v1/recordings");
      let form = Form::new().file("file", path).await?;
      let form = add_recording_changeset_fields(form, changeset);
      let builder = client.post(url).multipart(form);
  
      Ok(add_headers(builder, &install_id))
  }
  
  fn add_recording_changeset_fields(mut form: Form, changeset: RecordingChangeset) -> Form {
      if let Some(Some(title)) = changeset.title {
          form = form.text("title", title);
      }
  
      if let Some(Some(description)) = changeset.description {
          form = form.text("description", description);
      }
  
      if let Some(visibility) = changeset.visibility {
          let visibility = match visibility {
              Visibility::Public => "public",
              Visibility::Unlisted => "unlisted",
              Visibility::Private => "private",
          };
  
          form = form.text("visibility", visibility);
      }
  
      if let Some(Some(audio_url)) = changeset.audio_url {
          form = form.text("audio_url", audio_url);
      }
  
      form
  }
  
  pub async fn list_user_streams(prefix: &str, config: &mut Config) -> Result<Vec<StreamResponse>> {
      let server_url = config.get_server_url()?;
      let install_id = config.get_install_id()?;
  
      let response = list_user_streams_request(&server_url, prefix, &install_id)
          .send()
          .await
          .context("cannot obtain stream producer endpoint - is the server down?")?;
  
      parse_stream_response(response, &server_url).await
  }
  
  fn list_user_streams_request(server_url: &Url, prefix: &str, install_id: &str) -> RequestBuilder {
      let client = Client::new();
      let mut url = server_url.clone();
      url.set_path("api/v1/user/streams");
      url.set_query(Some(&format!("prefix={prefix}&limit=10")));
  
      add_headers(client.get(url), install_id)
  }
  
  pub async fn create_stream(
      changeset: StreamChangeset,
      config: &mut Config,
  ) -> Result<StreamResponse> {
      let server_url = config.get_server_url()?;
      let install_id = config.get_install_id()?;
  
      let response = create_stream_request(&server_url, &install_id, changeset)
          .send()
          .await
          .context("cannot obtain stream producer endpoint - is the server down?")?;
  
      parse_stream_response(response, &server_url).await
  }
  
  fn create_stream_request(
      server_url: &Url,
      install_id: &str,
      changeset: StreamChangeset,
  ) -> RequestBuilder {
      let client = Client::new();
      let mut url = server_url.clone();
      url.set_path("api/v1/streams");
      let builder = client.post(url);
      let builder = add_headers(builder, install_id);
  
      builder.json(&changeset)
  }
  
  pub async fn update_stream(
      stream_id: u64,
      changeset: StreamChangeset,
      config: &mut Config,
  ) -> Result<StreamResponse> {
      let server_url = config.get_server_url()?;
      let install_id = config.get_install_id()?;
  
      let response = update_stream_request(&server_url, &install_id, stream_id, changeset)
          .send()
          .await
          .context("cannot obtain stream producer endpoint - is the server down?")?;
  
      parse_stream_response(response, &server_url).await
  }
  
  fn update_stream_request(
      server_url: &Url,
      install_id: &str,
      stream_id: u64,
      changeset: StreamChangeset,
  ) -> RequestBuilder {
      let client = Client::new();
      let mut url = server_url.clone();
      url.set_path(&format!("api/v1/streams/{stream_id}"));
      let builder = client.patch(url);
      let builder = add_headers(builder, install_id);
  
      builder.json(&changeset)
  }
  
  async fn parse_stream_response<T: DeserializeOwned>(
      response: Response,
      server_url: &Url,
  ) -> Result<T> {
      let server_hostname = server_url.host().unwrap();
  
      match response.status().as_u16() {
          401 => bail!(
              "this CLI hasn't been authenticated with {server_hostname} - run `asciinema auth` first"
          ),
  
          404 => match response.json::<ErrorResponse>().await {
              Ok(json) => bail!("{}", json.message),
              Err(_) => bail!("{server_hostname} doesn't support streaming"),
          },
  
          422 => match response.json::<ErrorResponse>().await {
              Ok(json) => bail!("{}", json.message),
              Err(_) => bail!("{server_hostname} doesn't support streaming"),
          },
  
          _ => {
              response.error_for_status_ref()?;
          }
      }
  
      response.json::<T>().await.map_err(|e| e.into())
  }
  
  fn add_headers(builder: RequestBuilder, install_id: &str) -> RequestBuilder {
      builder
          .basic_auth(get_username(), Some(install_id))
          .header(header::USER_AGENT, build_user_agent())
          .header(header::ACCEPT, "application/json")
  }
  
  fn get_username() -> String {
      env::var("USER").unwrap_or("".to_owned())
  }
  
  pub fn build_user_agent() -> String {
      let ua = concat!(
          "asciinema/",
          env!("CARGO_PKG_VERSION"),
          " target/",
          env!("TARGET")
      );
  
      ua.to_owned()
  }
  