Clippy's `use_self` line ensures that `Self` is used instead of the real name whenever possible. This makes searching easier and cleans up the code a bit.
344 lines
11 KiB
Rust
344 lines
11 KiB
Rust
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
|
use std::{fs, process};
|
|
|
|
use cursive::theme::Theme;
|
|
use log::{debug, error};
|
|
use platform_dirs::AppDirs;
|
|
|
|
use crate::command::{SortDirection, SortKey};
|
|
use crate::model::playable::Playable;
|
|
use crate::queue;
|
|
use crate::serialization::{Serializer, CBOR, TOML};
|
|
|
|
pub const CLIENT_ID: &str = "d420a117a32841c2b3474932e49fb54b";
|
|
pub const CACHE_VERSION: u16 = 1;
|
|
pub const DEFAULT_COMMAND_KEY: char = ':';
|
|
|
|
/// The playback state when ncspot is started.
|
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
|
pub enum PlaybackState {
|
|
Playing,
|
|
Paused,
|
|
Stopped,
|
|
Default,
|
|
}
|
|
|
|
/// The focussed library tab when ncspot is started.
|
|
#[derive(Clone, Serialize, Deserialize, Debug, Hash, strum_macros::EnumIter)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum LibraryTab {
|
|
Tracks,
|
|
Albums,
|
|
Artists,
|
|
Playlists,
|
|
Podcasts,
|
|
Browse,
|
|
}
|
|
|
|
/// The format used to represent tracks in a list.
|
|
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
|
|
pub struct TrackFormat {
|
|
pub left: Option<String>,
|
|
pub center: Option<String>,
|
|
pub right: Option<String>,
|
|
}
|
|
|
|
impl TrackFormat {
|
|
pub fn default() -> Self {
|
|
Self {
|
|
left: Some(String::from("%artists - %title")),
|
|
center: Some(String::from("%album")),
|
|
right: Some(String::from("%saved %duration")),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The format used when sending desktop notifications about playback status.
|
|
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
|
|
pub struct NotificationFormat {
|
|
pub title: Option<String>,
|
|
pub body: Option<String>,
|
|
}
|
|
|
|
impl NotificationFormat {
|
|
pub fn default() -> Self {
|
|
Self {
|
|
title: Some(String::from("%title")),
|
|
body: Some(String::from("%artists")),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The configuration of ncspot.
|
|
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
|
|
pub struct ConfigValues {
|
|
pub command_key: Option<char>,
|
|
pub initial_screen: Option<String>,
|
|
pub default_keybindings: Option<bool>,
|
|
pub keybindings: Option<HashMap<String, String>>,
|
|
pub theme: Option<ConfigTheme>,
|
|
pub use_nerdfont: Option<bool>,
|
|
pub flip_status_indicators: Option<bool>,
|
|
pub audio_cache: Option<bool>,
|
|
pub audio_cache_size: Option<u32>,
|
|
pub backend: Option<String>,
|
|
pub backend_device: Option<String>,
|
|
pub volnorm: Option<bool>,
|
|
pub volnorm_pregain: Option<f64>,
|
|
pub notify: Option<bool>,
|
|
pub bitrate: Option<u32>,
|
|
pub gapless: Option<bool>,
|
|
pub shuffle: Option<bool>,
|
|
pub repeat: Option<queue::RepeatSetting>,
|
|
pub cover_max_scale: Option<f32>,
|
|
pub playback_state: Option<PlaybackState>,
|
|
pub track_format: Option<TrackFormat>,
|
|
pub notification_format: Option<NotificationFormat>,
|
|
pub statusbar_format: Option<String>,
|
|
pub library_tabs: Option<Vec<LibraryTab>>,
|
|
pub hide_display_names: Option<bool>,
|
|
pub credentials: Option<Credentials>,
|
|
}
|
|
|
|
/// Commands used to obtain user credentials automatically.
|
|
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
|
|
pub struct Credentials {
|
|
pub username_cmd: Option<String>,
|
|
pub password_cmd: Option<String>,
|
|
}
|
|
|
|
/// The ncspot theme.
|
|
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
|
|
pub struct ConfigTheme {
|
|
pub background: Option<String>,
|
|
pub primary: Option<String>,
|
|
pub secondary: Option<String>,
|
|
pub title: Option<String>,
|
|
pub playing: Option<String>,
|
|
pub playing_selected: Option<String>,
|
|
pub playing_bg: Option<String>,
|
|
pub highlight: Option<String>,
|
|
pub highlight_bg: Option<String>,
|
|
pub highlight_inactive_bg: Option<String>,
|
|
pub error: Option<String>,
|
|
pub error_bg: Option<String>,
|
|
pub statusbar_progress: Option<String>,
|
|
pub statusbar_progress_bg: Option<String>,
|
|
pub statusbar: Option<String>,
|
|
pub statusbar_bg: Option<String>,
|
|
pub cmdline: Option<String>,
|
|
pub cmdline_bg: Option<String>,
|
|
pub search_match: Option<String>,
|
|
}
|
|
|
|
/// The ordering that is used when representing a playlist.
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct SortingOrder {
|
|
pub key: SortKey,
|
|
pub direction: SortDirection,
|
|
}
|
|
|
|
/// The runtime state of the music queue.
|
|
#[derive(Serialize, Default, Deserialize, Debug, Clone)]
|
|
pub struct QueueState {
|
|
pub current_track: Option<usize>,
|
|
pub random_order: Option<Vec<usize>>,
|
|
pub track_progress: std::time::Duration,
|
|
pub queue: Vec<Playable>,
|
|
}
|
|
|
|
/// Runtime state that should be persisted accross sessions.
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct UserState {
|
|
pub volume: u16,
|
|
pub shuffle: bool,
|
|
pub repeat: queue::RepeatSetting,
|
|
pub queuestate: QueueState,
|
|
pub playlist_orders: HashMap<String, SortingOrder>,
|
|
pub cache_version: u16,
|
|
pub playback_state: PlaybackState,
|
|
}
|
|
|
|
impl Default for UserState {
|
|
fn default() -> Self {
|
|
Self {
|
|
volume: u16::MAX,
|
|
shuffle: false,
|
|
repeat: queue::RepeatSetting::None,
|
|
queuestate: QueueState::default(),
|
|
playlist_orders: HashMap::new(),
|
|
cache_version: 0,
|
|
playback_state: PlaybackState::Default,
|
|
}
|
|
}
|
|
}
|
|
|
|
lazy_static! {
|
|
/// Configuration files are read/written relative to this directory.
|
|
pub static ref BASE_PATH: RwLock<Option<PathBuf>> = RwLock::new(None);
|
|
}
|
|
|
|
/// The complete configuration (state + user configuration) of ncspot.
|
|
pub struct Config {
|
|
/// The configuration file path.
|
|
filename: String,
|
|
/// Configuration set by the user, read only.
|
|
values: RwLock<ConfigValues>,
|
|
/// Runtime state which can't be edited by the user, read/write.
|
|
state: RwLock<UserState>,
|
|
}
|
|
|
|
impl Config {
|
|
/// Generate the configuration from the user configuration file and the runtime state file.
|
|
/// `filename` can be used to look for a differently named configuration file.
|
|
pub fn new(filename: Option<String>) -> Self {
|
|
let filename = filename.unwrap_or("config.toml".to_owned());
|
|
let values = load(&filename).unwrap_or_else(|e| {
|
|
eprintln!("could not load config: {e}");
|
|
process::exit(1);
|
|
});
|
|
|
|
let mut userstate = {
|
|
let path = config_path("userstate.cbor");
|
|
CBOR.load_or_generate_default(path, || Ok(UserState::default()), true)
|
|
.expect("could not load user state")
|
|
};
|
|
|
|
if let Some(shuffle) = values.shuffle {
|
|
userstate.shuffle = shuffle;
|
|
}
|
|
|
|
if let Some(repeat) = values.repeat {
|
|
userstate.repeat = repeat;
|
|
}
|
|
|
|
if let Some(playback_state) = values.playback_state.clone() {
|
|
userstate.playback_state = playback_state;
|
|
}
|
|
|
|
Self {
|
|
filename,
|
|
values: RwLock::new(values),
|
|
state: RwLock::new(userstate),
|
|
}
|
|
}
|
|
|
|
pub fn values(&self) -> RwLockReadGuard<ConfigValues> {
|
|
self.values.read().expect("can't readlock config values")
|
|
}
|
|
|
|
pub fn state(&self) -> RwLockReadGuard<UserState> {
|
|
self.state.read().expect("can't readlock user state")
|
|
}
|
|
|
|
pub fn with_state_mut<F>(&self, cb: F)
|
|
where
|
|
F: Fn(RwLockWriteGuard<UserState>),
|
|
{
|
|
let state_guard = self.state.write().expect("can't writelock user state");
|
|
cb(state_guard);
|
|
}
|
|
|
|
pub fn save_state(&self) {
|
|
// update cache version number
|
|
self.with_state_mut(|mut state| state.cache_version = CACHE_VERSION);
|
|
|
|
let path = config_path("userstate.cbor");
|
|
debug!("saving user state to {}", path.display());
|
|
if let Err(e) = CBOR.write(path, self.state().clone()) {
|
|
error!("Could not save user state: {}", e);
|
|
}
|
|
}
|
|
|
|
pub fn build_theme(&self) -> Theme {
|
|
let theme = &self.values().theme;
|
|
crate::theme::load(theme)
|
|
}
|
|
|
|
/// Reload the configuration file.
|
|
pub fn reload(&self) {
|
|
let cfg = load(&self.filename).expect("could not reload config");
|
|
*self.values.write().expect("can't writelock config values") = cfg
|
|
}
|
|
}
|
|
|
|
/// Parse the configuration file with name `filename` at the configuration base path.
|
|
fn load(filename: &str) -> Result<ConfigValues, String> {
|
|
let path = config_path(filename);
|
|
TOML.load_or_generate_default(path, || Ok(ConfigValues::default()), false)
|
|
}
|
|
|
|
/// Returns the platform app directories for ncspot.
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// This panics if the project directories could not be determined. Use `try_proj_dirs` for a
|
|
/// non-panicking version.
|
|
fn proj_dirs() -> AppDirs {
|
|
try_proj_dirs().unwrap()
|
|
}
|
|
|
|
/// Returns the plaform app directories for ncspot if they could be determined,
|
|
/// or an error otherwise.
|
|
pub fn try_proj_dirs() -> Result<AppDirs, String> {
|
|
match *BASE_PATH
|
|
.read()
|
|
.map_err(|_| String::from("Poisoned RWLock"))?
|
|
{
|
|
Some(ref basepath) => Ok(AppDirs {
|
|
cache_dir: basepath.join(".cache"),
|
|
config_dir: basepath.join(".config"),
|
|
data_dir: basepath.join(".local/share"),
|
|
state_dir: basepath.join(".local/state"),
|
|
}),
|
|
None => AppDirs::new(Some("ncspot"), true)
|
|
.ok_or_else(|| String::from("Couldn't determine platform standard directories")),
|
|
}
|
|
}
|
|
|
|
/// Force create the configuration directory at the default project location, removing anything that
|
|
/// isn't a directory but has the same name. Return the path to the configuration file inside the
|
|
/// directory.
|
|
///
|
|
/// This doesn't create the file, only the containing directory.
|
|
pub fn config_path(file: &str) -> PathBuf {
|
|
let proj_dirs = proj_dirs();
|
|
let cfg_dir = &proj_dirs.config_dir;
|
|
if cfg_dir.exists() && !cfg_dir.is_dir() {
|
|
fs::remove_file(cfg_dir).expect("unable to remove old config file");
|
|
}
|
|
if !cfg_dir.exists() {
|
|
fs::create_dir_all(cfg_dir).expect("can't create config folder");
|
|
}
|
|
let mut cfg = cfg_dir.to_path_buf();
|
|
cfg.push(file);
|
|
cfg
|
|
}
|
|
|
|
/// Create the cache directory at the default project location, preserving it if it already exists,
|
|
/// and return the path to the cache file inside the directory.
|
|
///
|
|
/// This doesn't create the file, only the containing directory.
|
|
pub fn cache_path(file: &str) -> PathBuf {
|
|
let proj_dirs = proj_dirs();
|
|
let cache_dir = &proj_dirs.cache_dir;
|
|
if !cache_dir.exists() {
|
|
fs::create_dir_all(cache_dir).expect("can't create cache folder");
|
|
}
|
|
let mut pb = cache_dir.to_path_buf();
|
|
pb.push(file);
|
|
pb
|
|
}
|
|
|
|
/// Set the configuration base path. All configuration files are read/written relative to this path.
|
|
pub fn set_configuration_base_path(base_path: Option<PathBuf>) {
|
|
if let Some(basepath) = base_path {
|
|
if !basepath.exists() {
|
|
fs::create_dir_all(&basepath).expect("could not create basepath directory");
|
|
}
|
|
*BASE_PATH.write().unwrap() = Some(basepath);
|
|
}
|
|
}
|