diff --git a/src/rust/amadeus-next/Cargo.toml b/src/rust/amadeus-next/Cargo.toml index c6675a90c1242b1ff3a303f580caee3e22eaa7f8..d7be59e79b50a4e63369fd2623137536b44926b1 100644 --- a/src/rust/amadeus-next/Cargo.toml +++ b/src/rust/amadeus-next/Cargo.toml @@ -17,12 +17,14 @@ license = "MIT" [workspace.dependencies] log = "0.4" lazy_static = "1" +toml = { version = "^0.5", features = ["preserve_order"] } serde = { version = "^1", default-features = false, features = [ "std", "derive", ] } -tokio = { version = "1", default-features = false, features = [ +tokio = { version = "1", features = [ "rt", + "rt-multi-thread", "net", "time", "macros", diff --git a/src/rust/amadeus-next/amalib/src/lib.rs b/src/rust/amadeus-next/amalib/src/lib.rs index 33f96712e4480abb86ce1dd76c8371471a740197..4c5c6a933e1ce4625db715a5062d75748eedbc4f 100644 --- a/src/rust/amadeus-next/amalib/src/lib.rs +++ b/src/rust/amadeus-next/amalib/src/lib.rs @@ -16,6 +16,23 @@ pub use uri::*; pub(crate) use commons::{log::*, *}; pub(crate) use std::str::FromStr; +/// Whever the query uri should be only the direct query or must include the +/// first links. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub enum LektorQueryType { + /// Only direct results of the query. + DirectQuery, + + /// Also includes the first links of the direct query. + QueryPlusLink, +} + +impl Default for LektorQueryType { + fn default() -> Self { + LektorQueryType::QueryPlusLink + } +} + /// The playback state of the lektord server. #[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq, Copy)] pub enum LektorState { diff --git a/src/rust/amadeus-next/amalib/src/query.rs b/src/rust/amadeus-next/amalib/src/query.rs index bae0d763e9fa370f3db1c5e4827469352a511efd..fda3e8d16e3b3336411bbefb4a061d9e85c79d9f 100644 --- a/src/rust/amadeus-next/amalib/src/query.rs +++ b/src/rust/amadeus-next/amalib/src/query.rs @@ -33,6 +33,11 @@ pub enum LektorQuery { InsertKara(LektorUri), AddKara(LektorUri), + CreatePlaylist(String), + DestroyPlaylist(String), + AddToPlaylist(String, LektorUri), + RemoveFromPlaylist(String, LektorUri), + Continuation(usize, Box<LektorQuery>), } @@ -91,6 +96,11 @@ impl LektorQuery { Ping | Close | KillServer | PlayNext | PlayPrevious | ShuffleQueue | InsertKara(_) | AddKara(_) => LektorEmptyResponse::from_formated, + CreatePlaylist(_) + | DestroyPlaylist(_) + | AddToPlaylist(_, _) + | RemoveFromPlaylist(_, _) => LektorEmptyResponse::from_formated, + ListAllPlaylists => LektorPlaylistSetResponse::from_formated, PlaybackStatus => LektorPlaybackStatusResponse::from_formated, CurrentKara => LektorCurrentKaraResponse::from_formated, @@ -121,6 +131,13 @@ impl LektorQuery { | PlayNext | PlayPrevious | ShuffleQueue | ListAllPlaylists | ListPlaylist(_) | CurrentKara | Ping => Ok(()), + CreatePlaylist(str) + | DestroyPlaylist(str) + | AddToPlaylist(str, _) + | RemoveFromPlaylist(str, _) => { + either!(!str.is_empty() => Ok(()); Err("need to pass a non-empty string as playlist name".to_string())) + } + // Should be admin commands Close => Err("close is an admin command".to_string()), KillServer => Err("kill server is an admin command".to_string()), @@ -179,6 +196,11 @@ impl std::fmt::Display for LektorQuery { InsertKara(uri) => writeln!(f, "__insert {uri}"), AddKara(uri) => writeln!(f, "add {uri}"), + CreatePlaylist(name) => todo!("create playlist {name}"), + DestroyPlaylist(name) => todo!("destroy playlist {name}"), + AddToPlaylist(name, uri) => todo!("add to playlist {name} {uri}"), + RemoveFromPlaylist(name, uri) => todo!("remove from playlist {name} {uri}"), + Continuation(cont, query) => write!(f, "{cont} {query}"), } } diff --git a/src/rust/amadeus-next/amalib/src/uri.rs b/src/rust/amadeus-next/amalib/src/uri.rs index c8fbc724145e1038e9ab6ac2135c8d478a6afdf0..a006c5779ef8c36a4cde04ca1eaf7df68e04b7df 100644 --- a/src/rust/amadeus-next/amalib/src/uri.rs +++ b/src/rust/amadeus-next/amalib/src/uri.rs @@ -3,27 +3,115 @@ use crate::*; use std::str::FromStr; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum LektorUri { Id(i32), Author(String), Playlist(String), + Language(String), + Type(String), + Origin(String), Query(Vec<String>), + QueryPlusLink(Vec<String>), +} + +pub struct LektorUriBuilder<T: IntoIterator<Item = String> + Clone> { + query_ty: LektorQueryType, + strings: Option<T>, +} + +impl<T: IntoIterator<Item = String> + Clone> Default for LektorUriBuilder<T> { + fn default() -> Self { + Self { + query_ty: LektorQueryType::QueryPlusLink, + strings: None, + } + } +} + +impl<T: IntoIterator<Item = String> + Clone> LektorUriBuilder<T> { + pub fn query_type(mut self, ty: LektorQueryType) -> Self { + self.query_ty = ty; + self + } + + pub fn strings(mut self, strings: T) -> Self { + self.strings = Some(strings); + self + } + + /// Try to build an URI from user inputs. + pub fn try_build(self) -> Result<LektorUri, String> { + macro_rules! ret { + ($ty: ident, $var: expr) => { + return Ok(LektorUri::$ty($var)) + }; + } + + if self.strings.is_none() || self.strings.clone().into_iter().flatten().count() == 0 { + return Err("no user input was passed, can't build an empty uri".to_string()); + } + + // First we try to see if the URI is an id. + let mut args = self.strings.clone().into_iter().flatten(); + let first = args.next().ok_or_else(|| "invalid empty uri".to_string())?; + let next = args.next(); + match (first.parse::<i32>(), &next) { + (Ok(id), None) if id > 0 => ret!(Id, id), + _ => {} + } + + // Else we try to see if the URI is typed or not. Here we only permit + // one argument for the query (no space in names...). + match (&first[..], next, args.next()) { + ("author" | "auth", Some(str), None) => ret!(Author, str), + ("language" | "lang", Some(str), None) => ret!(Language, str), + ("playlist" | "plt", Some(str), None) => ret!(Playlist, str), + ("type", Some(str), None) => ret!(Type, str), + ("origin", Some(str), None) => ret!(Origin, str), + _ => {} + } + + // We have a query. We check the config to see if we must build a simple + // query or a query with links. + let strs = self.strings.into_iter().flatten().collect(); + match self.query_ty { + LektorQueryType::DirectQuery => ret!(Query, strs), + LektorQueryType::QueryPlusLink => ret!(QueryPlusLink, strs), + } + } } impl std::fmt::Display for LektorUri { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + macro_rules! uri { + ($uri: literal, $args: expr) => { + format!(concat!($uri, "://{}"), $args) + }; + } + + use LektorUri::*; let mut ret_str = match self { - Self::Id(id) => format!("id://{id}"), - Self::Author(author) => format!("author://{author}"), - Self::Playlist(plt_name) => format!("playlist://{plt_name}"), - Self::Query(sql_query) => { + Id(id) => uri!("id", id), + Type(ty) => uri!("type", ty), + Origin(origin) => uri!("origin", origin), + Author(author) => uri!("author", author), + Language(lang) => uri!("language", lang), + Playlist(name) => uri!("playlist", name), + Query(sql_query) => { const MAX_ARGS: usize = crate::constants::LKT_MESSAGE_ARGS_MAX; if sql_query.len() > MAX_ARGS { warn!("the query will be truncated to {MAX_ARGS} arguments") } format!("query://%{}%", sql_query[..MAX_ARGS].join(" ")) } + QueryPlusLink(sql_query) => { + const MAX_ARGS: usize = crate::constants::LKT_MESSAGE_ARGS_MAX; + if sql_query.len() > MAX_ARGS { + warn!("the query will be truncated to {MAX_ARGS} arguments") + } + format!("query+link://%{}%", sql_query[..MAX_ARGS].join(" ")) + } }; const MAX_CHARS: usize = crate::constants::LKT_MESSAGE_MAX - 1; @@ -39,6 +127,7 @@ impl std::fmt::Display for LektorUri { impl TryFrom<&str> for LektorUri { type Error = String; + /// Create an URI enum from the formated string fn try_from(value: &str) -> Result<Self, Self::Error> { match value.trim().split_once("://") { Some(("id", id)) => { @@ -51,6 +140,9 @@ impl TryFrom<&str> for LektorUri { Some(("query", query)) => Ok(Self::Query( query.split(' ').map(String::from).collect::<Vec<_>>(), )), + Some(("query+link", query)) => Ok(Self::Query( + query.split(' ').map(String::from).collect::<Vec<_>>(), + )), Some((identifier, _)) => Err(format!( "the identifier `{identifier}` is not a valid uri identifier" )), diff --git a/src/rust/amadeus-next/commons/Cargo.toml b/src/rust/amadeus-next/commons/Cargo.toml index a76970b4340f88edf0b23d3b1a0abf509af3bb64..1e71a6fe965f8ee9dc614bb9c1acaf1ec3c335d8 100644 --- a/src/rust/amadeus-next/commons/Cargo.toml +++ b/src/rust/amadeus-next/commons/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] log.workspace = true +lazy_static.workspace = true thiserror = { version = "^1", default-features = false } error-stack = { version = "^0.2", default-features = false, features = ["std"] } diff --git a/src/rust/amadeus-next/commons/src/lib.rs b/src/rust/amadeus-next/commons/src/lib.rs index 5322d4f839aa6bb9eaba7a2cb3dc1a65126802e7..7cd44cd06811bac7337380867fecf368915ace8f 100644 --- a/src/rust/amadeus-next/commons/src/lib.rs +++ b/src/rust/amadeus-next/commons/src/lib.rs @@ -3,5 +3,35 @@ mod error; mod macros; pub mod log; +pub mod logger; pub use error::*; + +/// Returns the home folder of the user. If no home folder is found log the +/// error and panic. This function check in order the following env variables to +/// determine the home folder: +/// 1. HOME +/// 2. HOMEPATH +/// 3. USERPROFILE +pub fn user_home_directory() -> std::path::PathBuf { + use std::env::var; + std::path::PathBuf::from(if let Ok(home) = var("HOME") { + home + } else if let Ok(home) = var("HOMEPATH") { + home + } else if let Ok(home) = var("USERPROFILE") { + home + } else { + panic!("failed to find a home folder for the user...") + }) +} + +/// Returns the config folder for the user. If neither HOME, HOMEPATH, +/// USERPROFILE are defined this function will panic. The config directory is +/// named `.config` and is placed in the home folder. This is the most common +/// behaviour on unix systems. +pub fn user_config_directory(app: impl AsRef<str>) -> std::path::PathBuf { + let folder = user_home_directory().join(".config").join(app.as_ref()); + std::fs::create_dir_all(&folder).expect("failed to create config folder for application"); + folder +} diff --git a/src/rust/amadeus-next/commons/src/logger.rs b/src/rust/amadeus-next/commons/src/logger.rs new file mode 100644 index 0000000000000000000000000000000000000000..4887005a1c4d5fa81d88d095482c149f353134a2 --- /dev/null +++ b/src/rust/amadeus-next/commons/src/logger.rs @@ -0,0 +1,89 @@ +use lazy_static::lazy_static; +use log::{Level, Metadata, Record, SetLoggerError}; +use std::sync::atomic::AtomicU8; + +struct SimpleLogger { + level: AtomicU8, +} + +#[repr(transparent)] +struct SimpleLoggerRef { + pub inner: SimpleLogger, +} + +lazy_static! { + static ref LOGGER: SimpleLoggerRef = SimpleLoggerRef { + inner: SimpleLogger { + level: AtomicU8::new(0) + } + }; +} + +impl SimpleLogger { + fn write_str(lvl: char, content: &str) { + content + .lines() + .filter(|content| !content.trim().is_empty()) + .for_each(|content| eprintln!("{lvl} {}", content)); + } + + fn level(&self) -> Level { + // Always display errors and warnings. + match self.level.load(std::sync::atomic::Ordering::SeqCst) { + 0 => Level::Warn, + 1 => Level::Info, + 2 => Level::Debug, + _ => Level::Trace, + } + } +} + +impl log::Log for SimpleLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + metadata.level() <= self.level() + } + + fn log(&self, record: &Record) { + if self.enabled(record.metadata()) { + let level = match record.level() { + Level::Error => '!', + Level::Warn => '*', + Level::Info => '#', + Level::Debug => '.', + Level::Trace => ' ', + }; + if let Some(s) = record.args().as_str() { + SimpleLogger::write_str(level, s) + } else { + SimpleLogger::write_str(level, &record.args().to_string()); + } + } + } + + fn flush(&self) {} +} + +pub fn level(lvl: u8) { + LOGGER + .inner + .level + .store(lvl, std::sync::atomic::Ordering::SeqCst); + log::set_max_level(LOGGER.inner.level().to_level_filter()); +} + +pub fn init(level: Option<Level>) -> Result<(), SetLoggerError> { + log::set_logger(&LOGGER.inner).map(|()| { + log::set_max_level(match level { + None => LOGGER.inner.level().to_level_filter(), + Some(level) => { + match level { + Level::Trace => self::level(3), + Level::Debug => self::level(2), + Level::Info => self::level(1), + Level::Warn | Level::Error => self::level(0), + }; + level.to_level_filter() + } + }); + }) +} diff --git a/src/rust/amadeus-next/lkt-rs/Cargo.toml b/src/rust/amadeus-next/lkt-rs/Cargo.toml index 2c5ad34a742179ad9eb332b1cb4fedaa74b194d4..c9757ad5717988d728efdca93f420a965b37222d 100644 --- a/src/rust/amadeus-next/lkt-rs/Cargo.toml +++ b/src/rust/amadeus-next/lkt-rs/Cargo.toml @@ -10,7 +10,9 @@ name = "lkt" path = "src/main.rs" [dependencies] +tokio.workspace = true serde.workspace = true +toml.workspace = true commons = { path = "../commons" } amalib = { path = "../amalib" } diff --git a/src/rust/amadeus-next/lkt-rs/src/args.rs b/src/rust/amadeus-next/lkt-rs/src/args.rs index c6971d9e2cfa28a4f3fcf7bdb39fd6d4d9c283ed..85518495950f5c7ad2f987b9009ee22cec8921ff 100644 --- a/src/rust/amadeus-next/lkt-rs/src/args.rs +++ b/src/rust/amadeus-next/lkt-rs/src/args.rs @@ -1,11 +1,10 @@ -use std::ops::Range; - -use crate::types::*; +use crate::{config::LktConfig, types::*}; use clap::{Parser, Subcommand}; +use std::ops::Range; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None, disable_help_subcommand = true, args_conflicts_with_subcommands = true)] -pub struct Args { +struct Args { #[command(subcommand)] action: SubCommand, @@ -19,81 +18,25 @@ pub struct Args { #[derive(Subcommand, Debug)] #[command(long_about = None, about = None)] -pub enum SubCommand { - // Status commands - #[command( - about = "Prints informations about the currently playing kara. Can be used to display the current kara in a status bar like xmobar or in the i3 panel." - )] - Current, - - #[command( - about = "Prints information about the state of lektor and the currently playing kara." - )] - Status, - - // Playback commands - #[command( - about = "Toggle play/pause state. If the playback is stopped, start at a possibly specified index." - )] - Play { index: Option<usize> }, - - // Playlist commands - #[command(short_flag = 'P')] - Playlist { - #[arg( value_parser = clap::builder::NonEmptyStringValueParser::new() - , exclusive = true - , short = 'c' - , long = "create" - , help = "Create a new playlist with a specific name." - )] - create: Option<String>, - - #[arg( value_parser = clap::builder::NonEmptyStringValueParser::new() - , exclusive = true - , short = 'd' - , long = "destroy" - , help = "Delete a playlist with all its content, do nothing if the playlist didn't exists." - )] - destroy: Option<String>, - - #[arg( action = clap::ArgAction::Set - , short = 'l' - , long = "list" - , exclusive = true - , help = "List the content of the playlist named if a name is passed. If missing returns a list of all the available playlists." - )] - list: Option<Option<String>>, - - #[arg( action = clap::ArgAction::Append - , exclusive = true - , num_args = 2.. - , short = 'r' - , long = "remove" - , help = "Deletes karas from a playlist with a valid query. The first element is the name of the playlist." - )] - remove: Option<Vec<String>>, - - #[arg( action = clap::ArgAction::Append - , exclusive = true - , num_args = 2.. - , short = 'a' - , long = "add" - , help = "Add karas to a playlist with a valid query. The first element is the name of the playlist." - )] - add: Option<Vec<String>>, - }, - +enum SubCommand { // Queue commands #[command(short_flag = 'Q')] Queue { - #[arg( action = clap::ArgAction::Set + #[arg( action = clap::ArgAction::SetTrue , exclusive = true - , short = 'S' - , long = "seek" - , help = "Goto to the kara with the specified id in the queue." - , id = "ID" + , short = 'I' + , long = "current" + , help = "Prints informations about the currently playing kara. Can be used to display the current kara in a status bar like xmobar or in the i3 panel." )] - seek: Option<usize>, + current: bool, + + #[arg( action = clap::ArgAction::SetTrue + , exclusive = true + , short = 'i' + , long = "info" + , help = "Prints information about the state of lektor and the currently playing kara." + )] + status: bool, #[arg( action = clap::ArgAction::Set , value_parser = crate::parsers::RangeParser::new() @@ -103,33 +46,7 @@ pub enum SubCommand { , help = "Prints the names and ids of karas in the queue. Karas are designated by a range of positions." , id = "LOWER:UPPER" )] - pos: Option<Range<usize>>, - - #[arg( action = clap::ArgAction::SetTrue - , exclusive = true - , short = 'C' - , long = "crop" - , help = "Crop the queue, delete every kara from it appart from the currently playing one." - )] - crop: bool, - - #[arg( action = clap::ArgAction::SetTrue - , exclusive = true - , short = 'c' - , long = "clear" - , help = "Clear the queue and set the state to stopped." - )] - clear: bool, - - #[arg( action = clap::ArgAction::Set - , exclusive = true - , num_args = 2 - , short = 's' - , long = "swap" - , help = "Swap two karas in the queue by their position" - , id = "POS" - )] - swap: Option<Vec<String>>, + pos: Option<Option<Range<usize>>>, #[arg( action = clap::ArgAction::SetTrue , exclusive = true @@ -145,14 +62,22 @@ pub enum SubCommand { , long = "previous" , help = "Play the previous kara in the queue." )] - previous: bool, + prev: bool, + + #[arg( action = clap::ArgAction::Set + , exclusive = true + , short = 'r' + , long = "play" + , help = "Toggle play/pause state. If the playback is stopped, start at a possibly specified index." + , id = "QUEUE_INDEX" + )] + play: Option<Option<usize>>, #[arg( action = clap::ArgAction::Set , exclusive = true , short = 'P' , long = "pause" , help = "Toggle the play/pause state. If an argument is passed, it sets the pause state to the value, converted to boolean." - , default_value = None )] pause: Option<Option<bool>>, @@ -164,16 +89,71 @@ pub enum SubCommand { )] stop: bool, - #[arg( action = clap::ArgAction::Set - , default_value = PriorityLevel::Add - , exclusive = true - , short = 'z' - , long = "shuffle" - , help = concat!( "Shuffle the queue. If lektord was paused it will unpause but if it was stopped it won't start. " - , "If it exists, the current kara will be placed in the first place in the queue. " - , "You can also pass a level to shuffle up to. The level can be assed as a numeric or its string representation" + #[arg( action = clap::ArgAction::Set + , exclusive = true + , raw = true + , last = false + , num_args = 1.. + , short = 'a' + , long = "add" + , id = "[PRIORITY] QUERY" + , help = concat!( "Add karas to the queue when they are matching the query. " + , "The priority can be passed as a numeric or as its string representation. " + , "[default priority: 1, possible priorities: 1..5]" ))] - shuffle: Option<PriorityLevel>, + add: Option<Vec<String>>, + + #[arg( action = clap::ArgAction::SetTrue + , exclusive = true + , short = 'C' + , long = "crop" + , help = "Crop the queue, delete every kara from it appart from the currently playing one." + )] + crop: bool, + + #[arg( action = clap::ArgAction::Set + , exclusive = true + , short = 'c' + , long = "clear" + , id = "CLEAR_LVL" + , default_missing_value = PriorityLevel::Enforce + , help = concat!( "Clear the queue and set the state to stopped. " + , "You can also pass a level to clear up to. The level can be passed as a numeric or as its string representation. " + , "[default value: 5, possible values: 1..5]" + ))] + clear: Option<Option<PriorityLevel>>, + + #[arg( action = clap::ArgAction::Set + , exclusive = true + , num_args = 2 + , short = 's' + , long = "swap" + , help = "Swap two karas in the queue by their position" + , id = "POS" + )] + swap: Option<Vec<String>>, + + #[arg( action = clap::ArgAction::Set + , exclusive = true + , short = 'S' + , long = "seek" + , help = "Goto to the kara with the specified id in the queue." + , id = "ID" + )] + seek: Option<usize>, + + #[arg( action = clap::ArgAction::Set + , exclusive = true + , short = 'z' + , long = "shuffle" + , id = "SHUFFLE_LVL" + , default_missing_value = PriorityLevel::Enforce + , help = concat!( "Shuffle the queue. If lektord was paused it will unpause but if it was stopped it won't start. " + , "If it exists, the current kara will be placed in the first place in the queue. " + , "You can also pass a level to shuffle up to. The level can be passed as a numeric or as its string representation. " + , "[default value: 5, possible values: 1..5]" + ))] + shuffle: Option<Option<PriorityLevel>>, }, // Search commands @@ -232,6 +212,52 @@ pub enum SubCommand { queue: Option<Vec<String>>, }, + // Playlist commands + #[command(short_flag = 'P')] + Playlist { + #[arg( value_parser = clap::builder::NonEmptyStringValueParser::new() + , exclusive = true + , short = 'c' + , long = "create" + , help = "Create a new playlist with a specific name." + )] + create: Option<String>, + + #[arg( value_parser = clap::builder::NonEmptyStringValueParser::new() + , exclusive = true + , short = 'd' + , long = "destroy" + , help = "Delete a playlist with all its content, do nothing if the playlist didn't exists." + )] + destroy: Option<String>, + + #[arg( action = clap::ArgAction::Set + , short = 'l' + , long = "list" + , exclusive = true + , help = "List the content of the playlist named if a name is passed. If missing returns a list of all the available playlists." + )] + list: Option<Option<String>>, + + #[arg( action = clap::ArgAction::Append + , exclusive = true + , num_args = 2.. + , short = 'a' + , long = "add" + , help = "Add karas to a playlist with a valid query. The first element is the name of the playlist." + )] + add: Option<Vec<String>>, + + #[arg( action = clap::ArgAction::Append + , exclusive = true + , num_args = 2.. + , short = 'r' + , long = "remove" + , help = "Deletes karas from a playlist with a valid query. The first element is the name of the playlist." + )] + remove: Option<Vec<String>>, + }, + // Admin commands #[command(short_flag = 'A')] Admin { @@ -263,19 +289,22 @@ pub enum SubCommand { , short = 'u' , long = "update" , group = "action" - , help = "Update the base from all the repos from the config file. Don't scan for new files in the filesystem." - )] + , help = concat!( "Update the base from all the repos from the config file. Don't scan for new files in the filesystem. " + , "With the --dry flag only fetch the information, don't download the kara from the repos." + ))] update: bool, #[arg( action = clap::ArgAction::SetTrue , short = 'P' , long = "populate" , group = "action" - , help = "Update the base from what is present on disk. The kara files and metadata files must be present on the disk." - )] + , help = concat!( "Update the base from what is present on disk. The kara files and metadata files must be present on the disk. " + , "With the --dry flag only add the found karas to the database, but don't mark them as downloaded." + ))] populate: bool, #[arg( action = clap::ArgAction::SetTrue + , short = 'd' , long = "dry" , help = concat!( "Don't do the action. For update only fetch the metadata and not the karas. " , "For the populate don't mark the karas as downloaded. This flag can't be " @@ -284,3 +313,249 @@ pub enum SubCommand { dry: bool, }, } + +/// The lkt command enum. The main should only handle that enum to decide what +/// to do based on user input. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LktCommand { + Queue(LktQueueCommand), + Search(LktSearchCommand), + Playlist(LktPlaylistCommand), + Admin(LktAdminCommand), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LktQueueCommand { + ShowCurrent, + + ShowStatus, + + List { + range: Range<usize>, + }, + + Next, + + Previous, + + Play { + index: usize, + }, + + Pause, + + UnPause, + + TogglePause, + + Stop, + + Add { + priority: PriorityLevel, + query: amalib::LektorUri, + }, + + Crop, + + Clear { + up_to_lvl: PriorityLevel, + }, + + SwapPositions { + p1: usize, + p2: usize, + }, + + SeekIdInQueue { + id: usize, + }, + + Shuffle { + up_to_lvl: PriorityLevel, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LktSearchCommand { + Database { + query: amalib::LektorUri, + }, + + DatabaseCount { + query: amalib::LektorUri, + }, + + Get { + id: usize, + }, + + Playlist { + name: String, + query: amalib::LektorUri, + }, + + Queue { + query: amalib::LektorUri, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LktPlaylistCommand { + Create { + name: String, + }, + + Destroy { + name: String, + }, + + Add { + name: String, + query: amalib::LektorUri, + }, + + Remove { + name: String, + query: amalib::LektorUri, + }, + + List, + + ListContent { + name: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LktAdminCommand { + Ping, + Kill, + Restart, + Update { dry: bool }, + Populate { dry: bool }, +} + +impl LktCommand { + pub fn parse(config: &LktConfig) -> Result<Self, String> { + let Args { action, verbose } = <Args as Parser>::parse(); + commons::logger::level(verbose); + + macro_rules! sterr { + ($($arg:tt)*) => {{ + let res = std::fmt::format(format_args!($($arg)*)); + return Err(res) + }}; + } + + use LktCommand::*; + #[rustfmt::skip] + let res = match action { + // QUEUE COMMANDS // + + SubCommand::Queue { crop: true, .. } => Queue(LktQueueCommand::Crop), + SubCommand::Queue { next: true, .. } => Queue(LktQueueCommand::Next), + SubCommand::Queue { prev: true, .. } => Queue(LktQueueCommand::Previous), + SubCommand::Queue { stop: true, .. } => Queue(LktQueueCommand::Stop), + SubCommand::Queue { current: true, .. } => { Queue(LktQueueCommand::ShowCurrent) } + SubCommand::Queue { status: true, .. } => { Queue(LktQueueCommand::ShowStatus) } + SubCommand::Queue { pause: Some(None), .. } => Queue(LktQueueCommand::TogglePause), + SubCommand::Queue { pause: Some(Some(true)), .. } => Queue(LktQueueCommand::Pause), + SubCommand::Queue { pause: Some(Some(false)), .. } => Queue(LktQueueCommand::UnPause), + SubCommand::Queue { shuffle: Some(lvl), .. } => Queue(LktQueueCommand::Shuffle { up_to_lvl: lvl.unwrap_or(PriorityLevel::Enforce) }), + SubCommand::Queue { clear: Some(lvl), .. } => Queue(LktQueueCommand::Clear { up_to_lvl: lvl.unwrap_or(PriorityLevel::Enforce) }), + SubCommand::Queue { seek: Some(id), .. } => { Queue(LktQueueCommand::SeekIdInQueue { id }) } + SubCommand::Queue { pos: Some(range), .. } => Queue(LktQueueCommand::List { range: range.unwrap_or_else(|| 0..usize::MAX) }), + SubCommand::Queue { play: Some(index), .. } => Queue(LktQueueCommand::Play { index: index.unwrap_or_default() }), + SubCommand::Queue { swap: Some(args), .. } => match &args[..] { + [p1, p2] => Queue(LktQueueCommand::SwapPositions { + p1: p1.parse::<usize>().map_err(|e| format!("invalid swap command, {p1} is not an integer: {e}"))?, + p2: p2.parse::<usize>().map_err(|e| format!("invalid swap command, {p2} is not an integer: {e}"))? + }), + _ => sterr!("invalid arguments passed to the queue swap command, expected two positions, got: {args:?}") + } + SubCommand::Queue { add: Some(args), .. } => match &args[..] { + [] => sterr!("invalid queue add command, expected `priority query...` or `query...`, got nothing"), + [priority, query @ .. ] if !query.is_empty() && priority.parse::<PriorityLevel>().is_ok() => Queue(LktQueueCommand::Add { + priority: priority.parse().expect("something went wrong"), + query: amalib::LektorUriBuilder::default() + .query_type(config.search.query_type).strings(query.iter().cloned()).try_build() + .map_err(|e| format!("invalid query in queue add command: {e}"))? + }), + query => Queue(LktQueueCommand::Add { priority: PriorityLevel::Add, + query: amalib::LektorUriBuilder::default() + .query_type(config.search.query_type).strings(query.iter().cloned()).try_build() + .map_err(|e| format!("invalid query in queue add command: {e}"))? + }), + } + + // SEARCH COMMANDS // + + SubCommand::Search { database: Some(query), .. } => Search(LktSearchCommand::Database { + query: amalib::LektorUriBuilder::default() + .query_type(config.search.query_type).strings(query.into_iter()).try_build() + .map_err(|e| format!("invalid query in search database command: {e}"))? + }), + SubCommand::Search { count: Some(query), .. } => Search(LktSearchCommand::DatabaseCount { query: + amalib::LektorUriBuilder::default() + .query_type(config.search.query_type).strings(query.into_iter()).try_build() + .map_err(|e| format!("invalid query in search count command: {e}"))? + }), + SubCommand::Search { queue: Some(query), .. } => Search(LktSearchCommand::Queue { query: + amalib::LektorUriBuilder::default() + .query_type(config.search.query_type).strings(query.into_iter()).try_build() + .map_err(|e| format!("invalid query in search queue command: {e}"))? + }), + SubCommand::Search { get: Some(id), .. } => Search(LktSearchCommand::Get { id }), + SubCommand::Search { plt: Some(args), .. } => match &args[..] { + [name, query @ ..] if !query.is_empty() => Search(LktSearchCommand::Playlist { name: name.clone(), query: + amalib::LektorUriBuilder::default() + .query_type(config.search.query_type).strings(query.iter().cloned()).try_build() + .map_err(|e| format!("invalid query in search playlist command: {e}"))? + }), + [] => sterr!("invalid search playlist command, expected `plt-name query...`, got nothing"), + _ => sterr!("invalid search playlist command, expected `plt-name query...`, got: {args:?}"), + } + + // PLAYLIST COMMANDS // + + SubCommand::Playlist { list: Some(None), .. } => Playlist(LktPlaylistCommand::List), + SubCommand::Playlist { list: Some(Some(name)), .. } => Playlist(LktPlaylistCommand::ListContent { name }), + SubCommand::Playlist { create: Some(name), .. } => Playlist(LktPlaylistCommand::Create { name }), + SubCommand::Playlist { destroy: Some(name), .. } => Playlist(LktPlaylistCommand::Destroy { name }), + SubCommand::Playlist { add: Some(args), .. } => match &args[..] { + [name, query @ ..] if !query.is_empty() => Playlist(LktPlaylistCommand::Add { name: name.clone(), query: + amalib::LektorUriBuilder::default() + .query_type(config.search.query_type).strings(query.iter().cloned()).try_build() + .map_err(|e| format!("invalid query in playlist add command: {e}"))? + }), + [] => sterr!("invalid playlist add command, expected `plt-name query...`, got nothing"), + _ => sterr!("invalid playlist add command, expected `plt-name query...`, got: {args:?}"), + } + SubCommand::Playlist { remove: Some(args), .. } => match &args[..] { + [name, query @ ..] if !query.is_empty() => Playlist(LktPlaylistCommand::Remove { name: name.clone(), query: + amalib::LektorUriBuilder::default() + .query_type(config.search.query_type).strings(query.iter().cloned()).try_build() + .map_err(|e| format!("invalid query in playlist add command: {e}"))? + }), + [] => sterr!("invalid playlist add command, expected `plt-name query...`, got nothing"), + _ => sterr!("invalid playlist remove command, expected `plt-name query...`, got: {args:?}",), + } + + // ADMIN COMMANDS // + + SubCommand::Admin { ping: true, .. } => Admin(LktAdminCommand::Ping), + SubCommand::Admin { kill: true, .. } => Admin(LktAdminCommand::Kill), + SubCommand::Admin { restart: true, .. } => Admin(LktAdminCommand::Restart), + SubCommand::Admin { update: true, dry, .. } => Admin(LktAdminCommand::Update { dry }), + SubCommand::Admin { populate: true, dry, .. } => Admin(LktAdminCommand::Populate { dry }), + + // FALLBACKS // + + SubCommand::Queue { .. } => sterr!("invalid queue command! check the help messages to see what are valid commands"), + SubCommand::Search { .. } => sterr!("invalid search command! check the help messages to see what are valid commands"), + SubCommand::Playlist { .. } => sterr!("invalid playlist command! check the help messages to see what are valid commands"), + SubCommand::Admin { .. } => sterr!("invalid admin command! check the help messages to see what are valid commands"), + }; + Ok(res) + } +} diff --git a/src/rust/amadeus-next/lkt-rs/src/config.rs b/src/rust/amadeus-next/lkt-rs/src/config.rs new file mode 100644 index 0000000000000000000000000000000000000000..d0ea7f9cee29a710a93a57604d3d2ea68d51c0c5 --- /dev/null +++ b/src/rust/amadeus-next/lkt-rs/src/config.rs @@ -0,0 +1,96 @@ +use amalib::LektorQueryType; + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(tag = "type", content = "port")] +#[allow(clippy::upper_case_acronyms)] +pub enum LktHostPort { + UNIX, + TCP(i16), +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct LktAdminConfig { + pub user: Option<String>, + pub password: Option<String>, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize, Default)] +pub struct LktSearchConfig { + pub query_type: LektorQueryType, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct LktHostConfig { + pub address: String, + pub socket: LktHostPort, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct LktConfig { + pub host: LktHostConfig, + pub admin: Option<LktAdminConfig>, + pub search: LktSearchConfig, +} + +impl Default for LktConfig { + fn default() -> Self { + Self { + host: Default::default(), + admin: Some(Default::default()), + search: Default::default(), + } + } +} + +impl Default for LktHostPort { + fn default() -> Self { + LktHostPort::TCP(6600) + } +} + +impl Default for LktAdminConfig { + fn default() -> Self { + Self { + user: Some("sakura".to_string()), + password: Some("sakura".to_string()), + } + } +} + +impl Default for LktHostConfig { + fn default() -> Self { + Self { + address: "127.0.0.1".to_string(), + socket: Default::default(), + } + } +} + +impl LktConfig { + pub fn has_valid_admin_config(&self) -> bool { + matches!(&self.admin, Some(LktAdminConfig { + user: Some(user), + password: Some(password), + }) if !user.trim().is_empty() && !password.trim().is_empty()) + } +} + +pub fn get_or_write_default() -> Result<LktConfig, String> { + let path = commons::user_config_directory("amadeus").join("lkt.toml"); + match std::fs::read_to_string(&path) { + Ok(config) => toml::from_str(&config).map_err(|err| { + let path = path.to_string_lossy(); + format!("invalid config file `{path}`: {err}") + }), + Err(_) => { + let default_config = LktConfig::default(); + let pretty_config = toml::to_string_pretty(&default_config) + .expect("failed to prettify the default config..."); + std::fs::write(&path, pretty_config).map_err(|err| { + let path = path.to_string_lossy(); + format!("failed to write default config to file `{path}`: {err}") + })?; + Ok(default_config) + } + } +} diff --git a/src/rust/amadeus-next/lkt-rs/src/main.rs b/src/rust/amadeus-next/lkt-rs/src/main.rs index 943affe76681d30fa437d2c90ea9f9a3706d0e86..382301334c7426646f0c0a6b609f9163a893a7de 100644 --- a/src/rust/amadeus-next/lkt-rs/src/main.rs +++ b/src/rust/amadeus-next/lkt-rs/src/main.rs @@ -1,8 +1,105 @@ mod args; +mod config; mod parsers; mod types; -fn main() { - let args = <args::Args as clap::Parser>::parse(); - println!("{args:#?}"); +#[tokio::main(worker_threads = 2)] +async fn main() { + commons::Report::install_debug_hook::<std::backtrace::Backtrace>(|_, _| {}); + commons::logger::init(Some(commons::log::Level::Trace)) + .unwrap_or_else(|e| panic!("failed to install logger: {e}")); + let config = config::get_or_write_default().expect("failed to get or write default config"); + + let cmd = args::LktCommand::parse(&config).expect("oupsy"); + handle_cmd(config, cmd).await +} + +async fn handle_cmd(config: config::LktConfig, cmd: args::LktCommand) { + commons::log::debug!("{config:#?}\ncmd = {cmd:#?}"); + let conn = match config.host.socket { + config::LktHostPort::UNIX => unimplemented!("connexion to unix socket is not implemented"), + config::LktHostPort::TCP(port) => amalib::LektorConnexion::new(&config.host.address, port) + .await + .expect("failed to connect to the lektord server"), + }; + match cmd { + args::LktCommand::Queue(cmd) => handle_cmd_queue(config, conn, cmd).await, + args::LktCommand::Search(cmd) => handle_cmd_search(config, conn, cmd).await, + args::LktCommand::Playlist(cmd) => handle_cmd_playlist(config, conn, cmd).await, + args::LktCommand::Admin(cmd) => handle_cmd_admin(config, conn, cmd).await, + } +} + +macro_rules! send { + ($conn: expr => $query: expr ; $expect: pat_param => $action: block) => { + match $conn.send($query).await.expect("failed to execute command") { + $expect => $action, + _ => panic!("invalid response type for command"), + } + }; + + ($conn: expr => $query: expr ; ok) => {{ + let _ = $conn.send($query).await.expect("failed to execute command"); + }}; +} + +async fn handle_cmd_queue( + _: config::LktConfig, + _: amalib::LektorConnexion, + _: args::LktQueueCommand, +) { + unimplemented!() +} + +async fn handle_cmd_search( + _: config::LktConfig, + _: amalib::LektorConnexion, + _: args::LktSearchCommand, +) { + unimplemented!() +} + +async fn handle_cmd_playlist( + _: config::LktConfig, + mut conn: amalib::LektorConnexion, + cmd: args::LktPlaylistCommand, +) { + match cmd { + args::LktPlaylistCommand::Create { name } => { + send!(conn => amalib::LektorQuery::CreatePlaylist(name); ok) + } + args::LktPlaylistCommand::Destroy { name } => { + send!(conn => amalib::LektorQuery::DestroyPlaylist(name); ok) + } + args::LktPlaylistCommand::Add { name, query } => { + send!(conn => amalib::LektorQuery::AddToPlaylist(name, query); ok) + } + args::LktPlaylistCommand::Remove { name, query } => { + send!(conn => amalib::LektorQuery::RemoveFromPlaylist(name, query); ok) + } + args::LktPlaylistCommand::List => { + send!(conn => amalib::LektorQuery::ListAllPlaylists + ; amalib::LektorResponse::PlaylistSet(playlists) => { + playlists.into_iter().for_each(|plt| println!("{plt}")) + }) + } + args::LktPlaylistCommand::ListContent { name } => { + send!(conn => amalib::LektorQuery::ListPlaylist(name) + ; amalib::LektorResponse::KaraSet(karas) => { + karas.into_iter().for_each(|kara| println!("{kara}")) + }) + } + } +} + +async fn handle_cmd_admin( + config: config::LktConfig, + _: amalib::LektorConnexion, + cmd: args::LktAdminCommand, +) { + if config.has_valid_admin_config() { + unimplemented!("handle admin command: {cmd:#?}") + } else { + panic!("no admin config block in config file") + } } diff --git a/src/rust/amadeus-next/lkt-rs/src/types.rs b/src/rust/amadeus-next/lkt-rs/src/types.rs index 70c63816d573cd136ddde3b5a499f670aa549df5..834e99ddd2698b1fcf5c69f0230795c659d8f735 100644 --- a/src/rust/amadeus-next/lkt-rs/src/types.rs +++ b/src/rust/amadeus-next/lkt-rs/src/types.rs @@ -1,4 +1,4 @@ -use commons::fatal; +use std::str::FromStr; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PriorityLevel { @@ -9,17 +9,25 @@ pub enum PriorityLevel { Enforce = 5, } -impl From<&str> for PriorityLevel { - fn from(value: &str) -> Self { +impl std::str::FromStr for PriorityLevel { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { use PriorityLevel::*; - match value { + Ok(match s { "add" | "1" => Add, "suggest" | "2" => Suggest, "insert" | "3" => Insert, "pick" | "4" => Pick, "enforce" | "5" => Enforce, - _ => fatal!("unknown priority level: {value}"), - } + _ => return Err(format!("unknown priority level: {s}")), + }) + } +} + +impl From<&str> for PriorityLevel { + fn from(value: &str) -> Self { + PriorityLevel::from_str(value).expect("conversion failed") } }