diff options
-rw-r--r-- | crates/yt/src/config/default.rs | 110 | ||||
-rw-r--r-- | crates/yt/src/config/definitions.rs | 67 | ||||
-rw-r--r-- | crates/yt/src/config/file_system.rs | 120 | ||||
-rw-r--r-- | crates/yt/src/config/mod.rs | 134 | ||||
-rw-r--r-- | crates/yt/src/config/paths.rs | 50 | ||||
-rw-r--r-- | crates/yt/src/config/support.rs | 151 | ||||
-rw-r--r-- | crates/yt/src/main.rs | 8 |
7 files changed, 274 insertions, 366 deletions
diff --git a/crates/yt/src/config/default.rs b/crates/yt/src/config/default.rs deleted file mode 100644 index 4ed643b..0000000 --- a/crates/yt/src/config/default.rs +++ /dev/null @@ -1,110 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::path::PathBuf; - -use anyhow::{Context, Result}; - -fn get_runtime_path(name: &'static str) -> Result<PathBuf> { - let xdg_dirs = xdg::BaseDirectories::with_prefix(PREFIX); - xdg_dirs - .place_runtime_file(name) - .with_context(|| format!("Failed to place runtime file: '{name}'")) -} -fn get_data_path(name: &'static str) -> Result<PathBuf> { - let xdg_dirs = xdg::BaseDirectories::with_prefix(PREFIX); - xdg_dirs - .place_data_file(name) - .with_context(|| format!("Failed to place data file: '{name}'")) -} -fn get_config_path(name: &'static str) -> Result<PathBuf> { - let xdg_dirs = xdg::BaseDirectories::with_prefix(PREFIX); - xdg_dirs - .place_config_file(name) - .with_context(|| format!("Failed to place config file: '{name}'")) -} - -pub(super) fn create_path(path: PathBuf) -> Result<PathBuf> { - if !path.exists() { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("Failed to create the '{}' directory", path.display()))?; - } - } - - Ok(path) -} - -pub(crate) const PREFIX: &str = "yt"; - -pub(crate) mod global { - pub(crate) fn display_colors() -> bool { - // TODO: This should probably check if the output is a tty and otherwise return `false` <2025-02-14> - true - } -} - -pub(crate) mod select { - pub(crate) fn playback_speed() -> f64 { - 2.7 - } - pub(crate) fn subtitle_langs() -> &'static str { - "" - } -} - -pub(crate) mod watch { - pub(crate) fn local_displays_length() -> usize { - 1000 - } -} - -pub(crate) mod update { - pub(crate) fn max_backlog() -> usize { - 20 - } -} - -pub(crate) mod paths { - use std::{env::temp_dir, path::PathBuf}; - - use anyhow::Result; - - use super::{PREFIX, create_path, get_config_path, get_data_path, get_runtime_path}; - - // We download to the temp dir to avoid taxing the disk - pub(crate) fn download_dir() -> Result<PathBuf> { - let temp_dir = temp_dir(); - - create_path(temp_dir.join(PREFIX)) - } - pub(crate) fn mpv_config_path() -> Result<PathBuf> { - get_config_path("mpv.conf") - } - pub(crate) fn mpv_input_path() -> Result<PathBuf> { - get_config_path("mpv.input.conf") - } - pub(crate) fn database_path() -> Result<PathBuf> { - get_data_path("videos.sqlite") - } - pub(crate) fn config_path() -> Result<PathBuf> { - get_config_path("config.toml") - } - pub(crate) fn last_selection_path() -> Result<PathBuf> { - get_runtime_path("selected.yts") - } -} - -pub(crate) mod download { - pub(crate) fn max_cache_size() -> &'static str { - "3 GiB" - } -} diff --git a/crates/yt/src/config/definitions.rs b/crates/yt/src/config/definitions.rs deleted file mode 100644 index ce8c0d4..0000000 --- a/crates/yt/src/config/definitions.rs +++ /dev/null @@ -1,67 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::path::PathBuf; - -use serde::Deserialize; - -#[derive(Debug, Deserialize, PartialEq)] -#[serde(deny_unknown_fields)] -pub(crate) struct ConfigFile { - pub global: Option<GlobalConfig>, - pub select: Option<SelectConfig>, - pub watch: Option<WatchConfig>, - pub paths: Option<PathsConfig>, - pub download: Option<DownloadConfig>, - pub update: Option<UpdateConfig>, -} - -#[derive(Debug, Deserialize, PartialEq, Clone, Copy)] -#[serde(deny_unknown_fields)] -pub(crate) struct GlobalConfig { - pub display_colors: Option<bool>, -} - -#[derive(Debug, Deserialize, PartialEq, Clone, Copy)] -#[serde(deny_unknown_fields)] -pub(crate) struct UpdateConfig { - pub max_backlog: Option<usize>, -} - -#[derive(Debug, Deserialize, PartialEq, Clone)] -#[serde(deny_unknown_fields)] -pub(crate) struct DownloadConfig { - /// This will then be converted to an u64 - pub max_cache_size: Option<String>, -} - -#[derive(Debug, Deserialize, PartialEq, Clone)] -#[serde(deny_unknown_fields)] -pub(crate) struct SelectConfig { - pub playback_speed: Option<f64>, - pub subtitle_langs: Option<String>, -} - -#[derive(Debug, Deserialize, PartialEq, Clone, Copy)] -#[serde(deny_unknown_fields)] -pub(crate) struct WatchConfig { - pub local_displays_length: Option<usize>, -} - -#[derive(Debug, Deserialize, PartialEq, Clone)] -#[serde(deny_unknown_fields)] -pub(crate) struct PathsConfig { - pub download_dir: Option<PathBuf>, - pub mpv_config_path: Option<PathBuf>, - pub mpv_input_path: Option<PathBuf>, - pub database_path: Option<PathBuf>, - pub last_selection_path: Option<PathBuf>, -} diff --git a/crates/yt/src/config/file_system.rs b/crates/yt/src/config/file_system.rs deleted file mode 100644 index 2463e9d..0000000 --- a/crates/yt/src/config/file_system.rs +++ /dev/null @@ -1,120 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use crate::config::{DownloadConfig, PathsConfig, SelectConfig, WatchConfig}; - -use super::{ - Config, GlobalConfig, UpdateConfig, - default::{create_path, download, global, paths, select, update, watch}, -}; - -use std::{fs::read_to_string, path::PathBuf}; - -use anyhow::{Context, Result}; -use bytes::Bytes; - -macro_rules! get { - ($default:path, $config:expr, $key_one:ident, $($keys:ident),*) => { - { - let maybe_value = get!{@option $config, $key_one, $($keys),*}; - if let Some(value) = maybe_value { - value - } else { - $default().to_owned() - } - } - }; - - (@option $config:expr, $key_one:ident, $($keys:ident),*) => { - if let Some(key) = $config.$key_one.clone() { - get!{@option key, $($keys),*} - } else { - None - } - }; - (@option $config:expr, $key_one:ident) => { - $config.$key_one - }; - - (@path_if_none $config:expr, $option_default:expr, $default:path, $key_one:ident, $($keys:ident),*) => { - { - let maybe_download_dir: Option<PathBuf> = - get! {@option $config, $key_one, $($keys),*}; - - let down_dir = if let Some(dir) = maybe_download_dir { - PathBuf::from(dir) - } else { - if let Some(path) = $option_default { - path - } else { - $default() - .with_context(|| format!("Failed to get default path for: '{}.{}'", stringify!($key_one), stringify!($($keys),*)))? - } - }; - create_path(down_dir)? - } - }; - (@path $config:expr, $default:path, $key_one:ident, $($keys:ident),*) => { - get! {@path_if_none $config, None, $default, $key_one, $($keys),*} - }; -} - -impl Config { - pub fn from_config_file( - db_path: Option<PathBuf>, - config_path: Option<PathBuf>, - display_colors: Option<bool>, - ) -> Result<Self> { - let config_file_path = - config_path.map_or_else(|| -> Result<_> { paths::config_path() }, Ok)?; - - let config: super::definitions::ConfigFile = - toml::from_str(&read_to_string(config_file_path).unwrap_or(String::new())) - .context("Failed to parse the config file as toml")?; - - Ok(Self { - global: GlobalConfig { - display_colors: { - let config_value: Option<bool> = get! {@option config, global, display_colors}; - - display_colors.unwrap_or(config_value.unwrap_or_else(global::display_colors)) - }, - }, - select: SelectConfig { - playback_speed: get! {select::playback_speed, config, select, playback_speed}, - subtitle_langs: get! {select::subtitle_langs, config, select, subtitle_langs}, - }, - watch: WatchConfig { - local_displays_length: get! {watch::local_displays_length, config, watch, local_displays_length}, - }, - update: UpdateConfig { - max_backlog: get! {update::max_backlog, config, update, max_backlog}, - }, - paths: PathsConfig { - download_dir: get! {@path config, paths::download_dir, paths, download_dir}, - mpv_config_path: get! {@path config, paths::mpv_config_path, paths, mpv_config_path}, - mpv_input_path: get! {@path config, paths::mpv_input_path, paths, mpv_input_path}, - database_path: get! {@path_if_none config, db_path, paths::database_path, paths, database_path}, - last_selection_path: get! {@path config, paths::last_selection_path, paths, last_selection_path}, - }, - download: DownloadConfig { - max_cache_size: { - let bytes_str: String = - get! {download::max_cache_size, config, download, max_cache_size}; - let number: Bytes = bytes_str - .parse() - .context("Failed to parse max_cache_size")?; - number - }, - }, - }) - } -} diff --git a/crates/yt/src/config/mod.rs b/crates/yt/src/config/mod.rs index a10f7c2..154a109 100644 --- a/crates/yt/src/config/mod.rs +++ b/crates/yt/src/config/mod.rs @@ -1,76 +1,74 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. +use crate::config::support::mk_config; -#![allow(clippy::module_name_repetitions)] +mod paths; +mod support; -use std::path::PathBuf; +mk_config! { + use std::path::PathBuf; + use std::io::IsTerminal; -use bytes::Bytes; -use serde::Serialize; + use crate::shared::bytes::Bytes; -mod default; -mod definitions; -pub mod file_system; + use super::paths::ensure_parent_dir; -#[derive(Serialize, Debug)] -pub struct Config { - pub global: GlobalConfig, - pub select: SelectConfig, - pub watch: WatchConfig, - pub paths: PathsConfig, - pub download: DownloadConfig, - pub update: UpdateConfig, -} -// These structures could get non-copy fields in the future. + struct Config { + global: GlobalConfig = { + /// Whether to display colors. + display_colors: bool where display_color: Option<bool> =! {|config_value: Option<bool>| + Ok::<_, anyhow::Error>( + display_color + .unwrap_or( + config_value + .unwrap_or_else(|| std::io::stderr().is_terminal()) + ) + ) + }, + }, + select: SelectConfig = { + /// The playback speed to use, when it is not overridden. + playback_speed: f64 =: 2.7, -#[derive(Serialize, Debug)] -#[allow(missing_copy_implementations)] -pub struct GlobalConfig { - pub display_colors: bool, -} -#[derive(Serialize, Debug)] -#[allow(missing_copy_implementations)] -pub struct UpdateConfig { - pub max_backlog: usize, -} -#[derive(Serialize, Debug)] -#[allow(missing_copy_implementations)] -pub struct DownloadConfig { - pub max_cache_size: Bytes, -} -#[derive(Serialize, Debug)] -pub struct SelectConfig { - pub playback_speed: f64, - pub subtitle_langs: String, -} -#[derive(Serialize, Debug)] -#[allow(missing_copy_implementations)] -pub struct WatchConfig { - pub local_displays_length: usize, -} -#[derive(Serialize, Debug)] -pub struct PathsConfig { - pub download_dir: PathBuf, - pub mpv_config_path: PathBuf, - pub mpv_input_path: PathBuf, - pub database_path: PathBuf, - pub last_selection_path: PathBuf, -} + /// The subtitle langs to download, when it is not overridden. + subtitle_langs: String =: String::new(), + }, + watch: WatchConfig = { + /// How many chars to display at most, when displaying information on mpv's local on screen + /// display. + local_displays_length: usize =: 1000, + }, + paths: PathsConfig = { + /// Where to store downloaded files. + download_dir: PathBuf =: { + // We download to the temp dir to avoid taxing the disk + let temp_dir = std::env::temp_dir(); + + temp_dir.join(super::paths::PREFIX) + } => ensure_parent_dir, + + /// Path to the mpv configuration file. + mpv_config_path: PathBuf =? super::paths::get_config_path("mpv.conf") => ensure_parent_dir, -// pub fn status_path() -> anyhow::Result<PathBuf> { -// const STATUS_PATH: &str = "running.info.json"; -// get_runtime_path(STATUS_PATH) -// } + /// Path to the mpv input configuration file. + mpv_input_path: PathBuf =? super::paths::get_config_path("mpv.input.conf") => ensure_parent_dir, -// pub fn subscriptions() -> anyhow::Result<PathBuf> { -// const SUBSCRIPTIONS: &str = "subscriptions.json"; -// get_data_path(SUBSCRIPTIONS) -// } + /// Which path to use for mpv ipc socket creation. + mpv_ipc_socket_path: PathBuf =? super::paths::get_runtime_path("mpv.ipc.socket") => ensure_parent_dir, + + /// Path to the video database. + database_path: PathBuf where db_path: Option<PathBuf> =! {|config_value: Option<PathBuf>| { + db_path.map_or_else(|| config_value.map_or_else(|| super::paths::get_data_path("videos.sqlite"), Ok), Ok) + }} => ensure_parent_dir, + + /// Where to store the selection file before applying it. + last_selection_path: PathBuf =? super::paths::get_runtime_path("selected.yts") => ensure_parent_dir, + }, + download: DownloadConfig = { + /// The maximum cache size. + max_cache_size: Bytes =? "3 GiB".parse(), + }, + update: UpdateConfig = { + /// How many videos to download, when checking for new ones. + max_backlog: usize =: 20, + }, + } +} diff --git a/crates/yt/src/config/paths.rs b/crates/yt/src/config/paths.rs new file mode 100644 index 0000000..224891b --- /dev/null +++ b/crates/yt/src/config/paths.rs @@ -0,0 +1,50 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +pub(super) fn get_runtime_path(name: &'static str) -> Result<PathBuf> { + let xdg_dirs = xdg::BaseDirectories::with_prefix(PREFIX); + xdg_dirs + .place_runtime_file(name) + .with_context(|| format!("Failed to place runtime file: '{name}'")) +} +pub(super) fn get_data_path(name: &'static str) -> Result<PathBuf> { + let xdg_dirs = xdg::BaseDirectories::with_prefix(PREFIX); + xdg_dirs + .place_data_file(name) + .with_context(|| format!("Failed to place data file: '{name}'")) +} +pub(super) fn get_config_path(name: &'static str) -> Result<PathBuf> { + let xdg_dirs = xdg::BaseDirectories::with_prefix(PREFIX); + xdg_dirs + .place_config_file(name) + .with_context(|| format!("Failed to place config file: '{name}'")) +} + +pub(super) fn ensure_parent_dir(path: &Path) -> Result<()> { + if !path.exists() { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create the '{}' directory", path.display()))?; + } + } + + Ok(()) +} + +pub(super) fn config_path() -> Result<PathBuf> { + get_config_path("config.toml") +} + +pub(crate) const PREFIX: &str = "yt"; diff --git a/crates/yt/src/config/support.rs b/crates/yt/src/config/support.rs new file mode 100644 index 0000000..880eba7 --- /dev/null +++ b/crates/yt/src/config/support.rs @@ -0,0 +1,151 @@ +macro_rules! mk_config { + ( + $(use $usage_path:path;)* + + struct $name:ident { + $( + $(#[$attr0:meta])* + $subconfig_name:ident : $subconfig_type:ident = { + $( + $(#[$attr1:meta])* + $field_name:ident : $field_type:ty $( + where $extra_input:ident: $extra_input_type:ty + ),* = $errors:tt $default:expr $(=> $finalizer:ident)? + ),* + $(,)? + } + ),* + $(,)? + } + ) => { + mod _inner { + #![allow(non_snake_case)] + + $(use $usage_path;)* + + #[derive(serde::Serialize, Debug)] + pub(crate) struct $name { + $( + $(#[$attr0])* + pub(crate) $subconfig_name: $subconfig_type + ),* + } + + #[derive(Debug, serde::Deserialize, PartialEq)] + #[serde(deny_unknown_fields)] + #[allow(non_camel_case_types)] + struct config { + $( + $subconfig_name: Option<$subconfig_name> + ),* + } + + impl $name { + pub(crate) fn from_config_file( + config_file_path: Option<std::path::PathBuf>, + $( + $( + $( + $extra_input: $extra_input_type, + )* + )* + )* + ) -> anyhow::Result<Self> { + use anyhow::Context; + + let config_file_path = + config_file_path.map_or_else(|| -> anyhow::Result<_> { super::paths::config_path() }, Ok)?; + + let config: config = + toml::from_str(&std::fs::read_to_string(config_file_path).unwrap_or(String::new())) + .context("Failed to parse the config file as toml")?; + + Ok(Self { + $( + $subconfig_name: { + let toplevel = config.$subconfig_name.unwrap_or_default(); + $subconfig_type { + $( + $field_name: $field_name(toplevel.$field_name, $($extra_input),*)? + ),* + } + } + ),* + }) + } + + pub(crate) fn run_finalizers(&self) -> anyhow::Result<()> { + #[allow(unused_imports)] + use anyhow::Context; + + $( + $( + $( + $finalizer(&self.$subconfig_name.$field_name) + .context( + concat!( + "While running the finalizer for config value '", + stringify!($subconfig_name), + ".", + stringify!($field_name), + "'" + ) + )?; + )? + )* + )* + + Ok(()) + } + } + + $( + #[derive(serde::Serialize, Debug)] + pub(crate) struct $subconfig_type { + $( + $(#[$attr1])* + pub(crate) $field_name: $field_type + ),* + } + + #[derive(Debug, Default, serde::Deserialize, PartialEq)] + #[serde(deny_unknown_fields)] + #[allow(non_camel_case_types)] + struct $subconfig_name { + $( + $field_name: Option<$field_type> + ),* + } + + $( + fn $field_name( + config_value: Option<$field_type>, + $($extra_input: $extra_input_type),* + ) -> anyhow::Result<$field_type> { + use anyhow::Context; + + let expr = $crate::config::support::maybe_wrap_type!($field_type =$errors $default)(config_value); + + expr.context(concat!("Failed to generate default config value for '", stringify!($field_name),"'")) + } + )* + )* + } + pub(crate) use self::_inner::*; + }; +} + +macro_rules! maybe_wrap_type { + ($ty:ty =! $val:expr) => { + (|config_value: Option<$ty>| $val(config_value)) + }; + ($ty:ty =? $val:expr) => { + (|config_value: Option<$ty>| config_value.map_or_else(|| $val, Ok)) + }; + ($ty:ty =: $val:expr) => { + (|config_value: Option<$ty>| Ok::<_, anyhow::Error>(config_value.unwrap_or_else(|| $val))) + }; +} + +pub(crate) use maybe_wrap_type; +pub(crate) use mk_config; diff --git a/crates/yt/src/main.rs b/crates/yt/src/main.rs index f6f18be..faee401 100644 --- a/crates/yt/src/main.rs +++ b/crates/yt/src/main.rs @@ -87,12 +87,18 @@ async fn main() -> Result<()> { } }); - let config = Config::from_config_file(args.db_path, args.config_path, args.color)?; + let config = Config::from_config_file(args.config_path, args.color, args.db_path)?; if args.version { version::show(&config).await?; return Ok(()); } + // Perform config finalization _after_ checking for the version + // so that version always works. + config + .run_finalizers() + .context("Failed to finalize config for usage")?; + let app = App::new(config, !args.no_migrate_db).await?; match args.command.unwrap_or(Command::default()) { |