spotatui 0.36.1

A Spotify client for the terminal written in Rust, powered by Ratatui
use super::banner::BANNER;
use anyhow::{anyhow, Error, Result};
use serde::{Deserialize, Serialize};
use std::{
  fs,
  io::{stdin, Write},
  path::{Path, PathBuf},
};

const DEFAULT_PORT: u16 = 8888;
const FILE_NAME: &str = "client.yml";
const CONFIG_DIR: &str = ".config";
const APP_CONFIG_DIR: &str = "spotatui";
const TOKEN_CACHE_FILE: &str = ".spotify_token_cache.json";
const GITIGNORE_FILE: &str = ".gitignore";
pub const NCSPOT_CLIENT_ID: &str = "d420a117a32841c2b3474932e49fb54b";
const AUTH_SETUP_VERSION: u8 = 2;

#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ClientConfig {
  pub client_id: String,
  #[serde(default)]
  pub fallback_client_id: Option<String>,
  #[serde(default)]
  pub client_secret: String,
  #[serde(default)]
  pub setup_version: u8,
  pub device_id: Option<String>,
  // FIXME: port should be defined in `user_config` not in here
  pub port: Option<u16>,
  // Streaming configuration
  #[serde(default = "default_streaming_enabled")]
  pub enable_streaming: bool,
  #[serde(default = "default_device_name")]
  pub streaming_device_name: String,
  #[serde(default = "default_bitrate")]
  pub streaming_bitrate: u16,
  #[serde(default)]
  pub streaming_audio_cache: bool,
}

fn default_streaming_enabled() -> bool {
  cfg!(feature = "streaming")
}

fn default_device_name() -> String {
  "spotatui".to_string()
}

fn default_bitrate() -> u16 {
  320
}

pub struct ConfigPaths {
  pub config_file_path: PathBuf,
  pub token_cache_path: PathBuf,
}

impl ClientConfig {
  pub fn new() -> ClientConfig {
    ClientConfig {
      client_id: "".to_string(),
      fallback_client_id: None,
      client_secret: "".to_string(),
      setup_version: 0,
      device_id: None,
      port: None,
      enable_streaming: default_streaming_enabled(),
      streaming_device_name: default_device_name(),
      streaming_bitrate: default_bitrate(),
      streaming_audio_cache: false,
    }
  }

  pub fn get_redirect_uri(&self) -> String {
    format!("http://127.0.0.1:{}/callback", self.get_port())
  }

  pub fn get_port(&self) -> u16 {
    self.port.unwrap_or(DEFAULT_PORT)
  }

  pub fn get_or_build_paths(&self) -> Result<ConfigPaths> {
    match dirs::home_dir() {
      Some(home) => {
        let path = Path::new(&home);
        let home_config_dir = path.join(CONFIG_DIR);
        let app_config_dir = home_config_dir.join(APP_CONFIG_DIR);

        if !home_config_dir.exists() {
          fs::create_dir(&home_config_dir)?;
        }

        if !app_config_dir.exists() {
          fs::create_dir(&app_config_dir)?;
        }

        // Create .gitignore to protect sensitive files from being committed
        let gitignore_path = app_config_dir.join(GITIGNORE_FILE);
        if !gitignore_path.exists() {
          let gitignore_content = "\
            # Protect sensitive files from being committed
            # This file is auto-generated by spotatui

            # Client credentials (client_id, client_secret)
            client.yml

            # Panic logs
            spotatui_panic.log

            # Streaming credentials
            streaming_cache/credentials.json
            ";
          fs::write(&gitignore_path, gitignore_content)?;
        }

        let config_file_path = &app_config_dir.join(FILE_NAME);
        let token_cache_path = &app_config_dir.join(TOKEN_CACHE_FILE);

        let paths = ConfigPaths {
          config_file_path: config_file_path.to_path_buf(),
          token_cache_path: token_cache_path.to_path_buf(),
        };

        Ok(paths)
      }
      None => Err(anyhow!("No $HOME directory found for client config")),
    }
  }

  pub fn set_device_id(&mut self, device_id: String) -> Result<()> {
    let paths = self.get_or_build_paths()?;
    let config_string = fs::read_to_string(&paths.config_file_path)?;
    let mut config_yml: ClientConfig = serde_yaml::from_str(&config_string)?;

    self.device_id = Some(device_id.clone());
    config_yml.device_id = Some(device_id);

    let new_config = serde_yaml::to_string(&config_yml)?;
    let mut config_file = fs::File::create(&paths.config_file_path)?;
    write!(config_file, "{}", new_config)?;
    Ok(())
  }

  pub fn load_config(&mut self) -> Result<()> {
    let paths = self.get_or_build_paths()?;
    if paths.config_file_path.exists() {
      let config_string = fs::read_to_string(&paths.config_file_path)?;
      let config_yml: ClientConfig = serde_yaml::from_str(&config_string)?;

      self.client_id = config_yml.client_id;
      self.fallback_client_id = config_yml.fallback_client_id;
      self.client_secret = config_yml.client_secret;
      self.setup_version = config_yml.setup_version;
      self.device_id = config_yml.device_id;
      self.port = config_yml.port;
      self.enable_streaming = config_yml.enable_streaming;
      self.streaming_device_name = config_yml.streaming_device_name;
      self.streaming_bitrate = config_yml.streaming_bitrate;
      self.streaming_audio_cache = config_yml.streaming_audio_cache;

      Ok(())
    } else {
      println!("{}", BANNER);

      println!(
        "Config will be saved to {}",
        paths.config_file_path.display()
      );

      self.run_auth_setup_wizard()
    }
  }

  pub fn needs_auth_setup_migration(&self) -> bool {
    self.setup_version < AUTH_SETUP_VERSION
  }

  pub fn reconfigure_auth(&mut self) -> Result<()> {
    self.run_auth_setup_wizard()
  }

  pub fn mark_auth_setup_migrated(&mut self) -> Result<()> {
    self.setup_version = AUTH_SETUP_VERSION;
    self.save_config_file()
  }

  fn run_auth_setup_wizard(&mut self) -> Result<()> {
    println!("\nClient setup options:\n");
    println!("  1) Use ncspot client ID (quick setup, may break if Spotify revokes shared access)");
    println!("  2) Use ncspot client ID + your own fallback app ID (recommended for resilience)");

    let setup_option = ClientConfig::get_setup_option()?;

    let (client_id, fallback_client_id) = match setup_option {
      1 => {
        println!("\nUsing ncspot redirect URI: http://127.0.0.1:8989/login");
        (NCSPOT_CLIENT_ID.to_string(), None)
      }
      2 => {
        println!("\nCreate your fallback Spotify app:\n");
        let instructions = [
          "Go to https://developer.spotify.com/dashboard/applications",
          "Click `Create app` and add your own name and description",
          &format!(
            "Add `http://127.0.0.1:{}/callback` to Redirect URIs",
            DEFAULT_PORT
          ),
        ];

        let mut number = 1;
        for item in instructions.iter() {
          println!("  {}. {}", number, item);
          number += 1;
        }

        let fallback = ClientConfig::get_client_key_from_input("Fallback Client ID")?;
        (NCSPOT_CLIENT_ID.to_string(), Some(fallback))
      }
      _ => unreachable!(),
    };

    let port = if setup_option == 1 {
      8989
    } else {
      let mut port = String::new();
      println!(
        "\nEnter port of fallback redirect uri (default {}): ",
        DEFAULT_PORT
      );
      stdin().read_line(&mut port)?;
      port.trim().parse::<u16>().unwrap_or(DEFAULT_PORT)
    };

    self.client_id = client_id;
    self.fallback_client_id = fallback_client_id;
    self.client_secret = String::new();
    self.port = Some(port);
    self.setup_version = AUTH_SETUP_VERSION;

    self.save_config_file()
  }

  fn save_config_file(&self) -> Result<()> {
    let paths = self.get_or_build_paths()?;
    let content_yml = serde_yaml::to_string(self)?;

    let mut config_file = fs::File::create(&paths.config_file_path)?;
    write!(config_file, "{}", content_yml)?;
    Ok(())
  }

  fn get_client_key_from_input(type_label: &'static str) -> Result<String> {
    let mut client_key = String::new();
    const MAX_RETRIES: u8 = 5;
    let mut num_retries = 0;
    loop {
      println!("\nEnter your {}: ", type_label);
      stdin().read_line(&mut client_key)?;
      client_key = client_key.trim().to_string();
      match ClientConfig::validate_client_key(&client_key) {
        Ok(_) => return Ok(client_key),
        Err(error_string) => {
          println!("{}", error_string);
          client_key.clear();
          num_retries += 1;
          if num_retries == MAX_RETRIES {
            return Err(Error::from(std::io::Error::other(format!(
              "Maximum retries ({}) exceeded.",
              MAX_RETRIES
            ))));
          }
        }
      };
    }
  }

  fn get_setup_option() -> Result<u8> {
    let mut input = String::new();
    const MAX_RETRIES: u8 = 5;
    let mut retries = 0;

    loop {
      println!("\nChoose option (1 or 2): ");
      stdin().read_line(&mut input)?;
      let trimmed = input.trim();

      match trimmed.parse::<u8>() {
        Ok(1) | Ok(2) => return Ok(trimmed.parse::<u8>()?),
        _ => {
          println!("Invalid choice. Please enter 1 or 2.");
          input.clear();
          retries += 1;
          if retries >= MAX_RETRIES {
            return Err(Error::from(std::io::Error::other(format!(
              "Maximum retries ({}) exceeded.",
              MAX_RETRIES
            ))));
          }
        }
      }
    }
  }

  fn validate_client_key(key: &str) -> Result<()> {
    const EXPECTED_LEN: usize = 32;
    if key.len() != EXPECTED_LEN {
      Err(Error::from(std::io::Error::new(
        std::io::ErrorKind::InvalidInput,
        format!("invalid length: {} (must be {})", key.len(), EXPECTED_LEN,),
      )))
    } else if !key.chars().all(|c| c.is_ascii_hexdigit()) {
      Err(Error::from(std::io::Error::new(
        std::io::ErrorKind::InvalidInput,
        "invalid character found (must be hex digits)",
      )))
    } else {
      Ok(())
    }
  }
}