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>,
pub port: Option<u16>,
#[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)?;
}
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(())
}
}
}