mod keymap;
mod theme;
const DEFAULT_CONFIG_FOLDER: &str = ".config/spotify-player";
const DEFAULT_CACHE_FOLDER: &str = ".cache/spotify-player";
const APP_CONFIG_FILE: &str = "app.toml";
const THEME_CONFIG_FILE: &str = "theme.toml";
const KEYMAP_CONFIG_FILE: &str = "keymap.toml";
use anyhow::{anyhow, Result};
use config_parser2::{config_parser_impl, ConfigParse, ConfigParser};
use librespot_core::config::SessionConfig;
use reqwest::Url;
use serde::{Deserialize, Serialize};
use std::{
path::{Path, PathBuf},
sync::OnceLock,
};
use keymap::KeymapConfig;
use theme::ThemeConfig;
pub use theme::Theme;
use crate::auth::{NCSPOT_CLIENT_ID, SPOTIFY_CLIENT_ID};
static CONFIGS: OnceLock<Configs> = OnceLock::new();
#[derive(Debug)]
pub struct Configs {
pub app_config: AppConfig,
pub keymap_config: KeymapConfig,
pub theme_config: ThemeConfig,
pub cache_folder: std::path::PathBuf,
}
impl Configs {
pub fn new(config_folder: &std::path::Path, cache_folder: &std::path::Path) -> Result<Self> {
Ok(Self {
app_config: AppConfig::new(config_folder)?,
keymap_config: KeymapConfig::new(config_folder)?,
theme_config: ThemeConfig::new(config_folder)?,
cache_folder: cache_folder.to_path_buf(),
})
}
}
#[derive(Debug, Deserialize, Serialize, ConfigParse)]
#[allow(clippy::struct_excessive_bools)]
pub struct AppConfig {
pub theme: String,
pub client_id: Option<String>,
pub client_id_command: Option<Command>,
pub client_port: u16,
pub login_redirect_uri: String,
pub log_folder: Option<PathBuf>,
pub player_event_hook_command: Option<Command>,
pub playback_format: String,
pub playback_metadata_fields: Vec<String>,
#[cfg(feature = "notify")]
pub notify_format: NotifyFormat,
#[cfg(feature = "notify")]
pub notify_timeout_in_secs: u64,
#[cfg(feature = "notify")]
#[cfg(all(unix, not(target_os = "macos")))]
pub notify_transient: bool,
pub tracks_playback_limit: usize,
pub proxy: Option<String>,
pub ap_port: Option<u16>,
pub app_refresh_duration_in_ms: u64,
pub playback_refresh_duration_in_ms: u64,
pub page_size_in_rows: usize,
pub play_icon: String,
pub pause_icon: String,
pub liked_icon: String,
pub explicit_icon: String,
pub border_type: BorderType,
pub progress_bar_type: ProgressBarType,
pub progress_bar_position: ProgressBarPosition,
pub layout: LayoutConfig,
pub genre_num: u8,
#[cfg(feature = "image")]
pub cover_img_length: usize,
#[cfg(feature = "image")]
pub cover_img_width: usize,
#[cfg(feature = "image")]
pub cover_img_scale: f32,
#[cfg(feature = "pixelate")]
pub cover_img_pixels: u32,
#[cfg(feature = "media-control")]
pub enable_media_control: bool,
pub enable_streaming: StreamingType,
#[cfg(feature = "streaming")]
pub enable_audio_visualization: bool,
#[cfg(feature = "notify")]
pub enable_notify: bool,
pub enable_cover_image_cache: bool,
pub default_device: String,
pub device: DeviceConfig,
#[cfg(all(feature = "streaming", feature = "notify"))]
pub notify_streaming_only: bool,
pub seek_duration_secs: u16,
pub sort_artist_albums_by_type: bool,
pub volume_scroll_step: u8,
pub enable_mouse_scroll_volume: bool,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub enum Position {
Top,
Bottom,
}
config_parser_impl!(Position);
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub enum BorderType {
Hidden,
Plain,
Rounded,
Double,
Thick,
}
config_parser_impl!(BorderType);
#[derive(Debug, Deserialize, Serialize, Clone)]
pub enum ProgressBarType {
Line,
Rectangle,
}
config_parser_impl!(ProgressBarType);
#[derive(Debug, Deserialize, Serialize, Clone)]
pub enum ProgressBarPosition {
Bottom,
Right,
}
config_parser_impl!(ProgressBarPosition);
#[derive(Debug, Deserialize, Serialize, ConfigParse, Clone)]
pub struct Command {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
}
impl Command {
pub fn execute(&self, extra_args: Option<Vec<String>>) -> anyhow::Result<String> {
let mut args = self.args.clone();
args.extend(extra_args.unwrap_or_default());
let output = std::process::Command::new(&self.command)
.args(&args)
.output()?;
if !output.status.success() {
let stderr = std::str::from_utf8(&output.stderr)?.to_string();
anyhow::bail!(stderr);
}
let stdout = std::str::from_utf8(&output.stdout)?.to_string();
Ok(stdout)
}
}
#[derive(Debug, Deserialize, Serialize, ConfigParse, Clone)]
pub struct DeviceConfig {
pub name: String,
pub device_type: String,
pub volume: u8,
pub bitrate: u16,
pub audio_cache: bool,
pub normalization: bool,
pub autoplay: bool,
}
#[derive(Debug, Deserialize, Serialize, ConfigParse, Clone)]
#[cfg(feature = "notify")]
pub struct NotifyFormat {
pub summary: String,
pub body: String,
}
#[derive(Debug, Deserialize, Serialize, ConfigParse, Clone)]
pub struct LayoutConfig {
pub library: LibraryLayoutConfig,
pub playback_window_position: Position,
pub playback_window_height: usize,
}
#[derive(Debug, Deserialize, Serialize, ConfigParse, Clone)]
pub struct LibraryLayoutConfig {
pub playlist_percent: u16,
pub album_percent: u16,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
#[serde(from = "StreamingTypeOrBool")]
pub enum StreamingType {
Always,
DaemonOnly,
Never,
}
config_parser_impl!(StreamingType);
#[derive(Deserialize)]
enum RawStreamingType {
Always,
DaemonOnly,
Never,
}
#[allow(dead_code)]
#[derive(Deserialize)]
#[serde(untagged)]
enum StreamingTypeOrBool {
Bool(bool),
Type(RawStreamingType),
}
impl From<StreamingTypeOrBool> for StreamingType {
fn from(v: StreamingTypeOrBool) -> Self {
match v {
StreamingTypeOrBool::Bool(true)
| StreamingTypeOrBool::Type(RawStreamingType::Always) => StreamingType::Always,
StreamingTypeOrBool::Bool(false)
| StreamingTypeOrBool::Type(RawStreamingType::Never) => StreamingType::Never,
StreamingTypeOrBool::Type(RawStreamingType::DaemonOnly) => StreamingType::DaemonOnly,
}
}
}
impl Command {
pub fn new<C, A>(command: C, args: &[A]) -> Self
where
C: std::fmt::Display,
A: std::fmt::Display,
{
Self {
command: command.to_string(),
args: args.iter().map(std::string::ToString::to_string).collect(),
}
}
}
impl Default for AppConfig {
fn default() -> Self {
Self {
theme: "dracula".to_owned(),
client_id: Some(NCSPOT_CLIENT_ID.to_string()),
client_id_command: None,
client_port: 8080,
login_redirect_uri: "http://127.0.0.1:8989/login".to_string(),
log_folder: None,
tracks_playback_limit: 50,
playback_format: String::from(
"{status} {track} • {artists} {liked}\n{album} • {genres}\n{metadata}",
),
playback_metadata_fields: vec![
"repeat".to_string(),
"shuffle".to_string(),
"volume".to_string(),
"device".to_string(),
],
#[cfg(feature = "notify")]
notify_format: NotifyFormat {
summary: String::from("{track} • {artists}"),
body: String::from("{album}"),
},
#[cfg(feature = "notify")]
notify_timeout_in_secs: 0,
#[cfg(feature = "notify")]
#[cfg(all(unix, not(target_os = "macos")))]
notify_transient: false,
player_event_hook_command: None,
proxy: None,
ap_port: None,
app_refresh_duration_in_ms: 32,
playback_refresh_duration_in_ms: 0,
page_size_in_rows: 20,
pause_icon: "▌▌".to_string(),
play_icon: "â–¶".to_string(),
liked_icon: "♥".to_string(),
explicit_icon: "(E)".to_string(),
border_type: BorderType::Plain,
progress_bar_type: ProgressBarType::Rectangle,
progress_bar_position: ProgressBarPosition::Bottom,
layout: LayoutConfig::default(),
genre_num: 2,
#[cfg(feature = "image")]
cover_img_length: 9,
#[cfg(feature = "image")]
cover_img_width: 5,
#[cfg(feature = "image")]
cover_img_scale: 1.0,
#[cfg(feature = "pixelate")]
cover_img_pixels: 16,
#[cfg(feature = "media-control")]
#[cfg(any(target_os = "macos", target_os = "windows"))]
enable_media_control: false,
#[cfg(feature = "media-control")]
#[cfg(all(unix, not(target_os = "macos")))]
enable_media_control: true,
enable_streaming: StreamingType::Always,
#[cfg(feature = "streaming")]
enable_audio_visualization: false,
#[cfg(feature = "notify")]
enable_notify: true,
enable_cover_image_cache: true,
default_device: "spotify-player".to_string(),
device: DeviceConfig::default(),
#[cfg(all(feature = "streaming", feature = "notify"))]
notify_streaming_only: false,
seek_duration_secs: 5,
sort_artist_albums_by_type: false,
volume_scroll_step: 5,
enable_mouse_scroll_volume: true,
}
}
}
impl Default for DeviceConfig {
fn default() -> Self {
Self {
name: "spotify-player".to_string(),
device_type: "speaker".to_string(),
volume: 70,
bitrate: 320,
audio_cache: false,
normalization: false,
autoplay: false,
}
}
}
impl Default for LayoutConfig {
fn default() -> Self {
Self {
library: LibraryLayoutConfig {
playlist_percent: 40,
album_percent: 40,
},
playback_window_position: Position::Top,
playback_window_height: 6,
}
}
}
impl LayoutConfig {
fn check_values(&self) -> anyhow::Result<()> {
if self.library.album_percent + self.library.playlist_percent > 99 {
anyhow::bail!("Invalid library layout: summation of album_percent and playlist_percent cannot be greater than 99!");
}
Ok(())
}
}
impl AppConfig {
pub fn new(path: &Path) -> Result<Self> {
let mut config = Self::default();
if !config.parse_config_file(path)? {
config.write_config_file(path)?;
}
config.layout.check_values()?;
Ok(config)
}
fn parse_config_file(&mut self, path: &Path) -> Result<bool> {
let file_path = path.join(APP_CONFIG_FILE);
match std::fs::read_to_string(file_path) {
Ok(content) => self
.parse(toml::from_str::<toml::Value>(&content)?)
.map(|()| true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
}
fn write_config_file(&self, path: &Path) -> Result<()> {
toml::to_string_pretty(&self)
.map_err(From::from)
.and_then(|content| {
std::fs::write(path.join(APP_CONFIG_FILE), content).map_err(From::from)
})
}
pub fn session_config(&self) -> SessionConfig {
let proxy = self
.proxy
.as_ref()
.and_then(|proxy| match Url::parse(proxy) {
Err(err) => {
tracing::warn!("failed to parse proxy url {proxy}: {err:#}");
None
}
Ok(url) => Some(url),
});
SessionConfig {
proxy,
ap_port: self.ap_port,
client_id: SPOTIFY_CLIENT_ID.to_string(),
autoplay: Some(self.device.autoplay),
..Default::default()
}
}
pub fn get_user_client_id(&self) -> Result<Option<String>> {
match self.client_id_command {
Some(ref cmd) => cmd.execute(None).map(|out| Some(out.trim().to_string())),
None => Ok(self.client_id.clone()),
}
}
}
pub fn get_config_folder_path() -> Result<PathBuf> {
match dirs_next::home_dir() {
Some(home) => Ok(home.join(DEFAULT_CONFIG_FOLDER)),
None => Err(anyhow!("cannot find the $HOME folder")),
}
}
pub fn get_cache_folder_path() -> Result<PathBuf> {
match dirs_next::home_dir() {
Some(home) => Ok(home.join(DEFAULT_CACHE_FOLDER)),
None => Err(anyhow!("cannot find the $HOME folder")),
}
}
pub fn get_config() -> &'static Configs {
CONFIGS.get().expect("configs is already initialized")
}
pub fn set_config(configs: Configs) {
CONFIGS
.set(configs)
.expect("configs should be initialized only once");
}