diff --git a/CMakeLists.txt b/CMakeLists.txt index 0421d3368bbf1ffc58230409ae81c941d842a641..66bad3c783d8daaefb315b3393446ad7a412505e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -334,7 +334,7 @@ ExternalProject_Add(amadeus_rs DOWNLOAD_COMMAND "" CONFIGURE_COMMAND "" INSTALL_COMMAND "" - SOURCE_DIR "${CMAKE_SOURCE_DIR}/src/rust/amadeus-rs" + SOURCE_DIR "${CMAKE_SOURCE_DIR}/src/rust/amadeus-next" BUILD_COMMAND ${RUST_BUILD_CMD} COMMAND ${RUST_BUILD_CMD} BUILD_BYPRODUCTS "${CMAKE_SOURCE_DIR}/src/rust/target/${RUST_BUILD_TYPE}/amadeus" diff --git a/README.md b/README.md index 5258599c7aec12d6ddccd4d20a9ac816b9bd34c0..4c81b61fdf9b64f09cee4b939fd1ff91e4f2a42b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# lektor mk7 +# Lektor mk7 [](https://git.iiens.net/martin2018/lektor/-/commits/master) [](https://matrix.to/#/#baka-dev-lektor:iiens.net) @@ -93,7 +93,7 @@ inside the lektor's config file. ### Launch instructions To run lektor, you can simply run the binary like: `./lektord`. If lektord did -not exited normally (i.e. without the `lkt adm kill` command), the database will +not exit normally (i.e. without the `lkt adm kill` command), the database will still store the fact that lektord is running. To by-pass it, you will need to launch lektord with the `-F` (forced) option, like `lektord -F`. It is not recommended to launch always lektord with the `-F` option, because that way you diff --git a/lektor.code-workspace b/lektor.code-workspace index 73cb939728b97d2a0e214fd1589442fcf92ef68b..92cad9d639e75851c03645ed9c8f0d43153f9748 100644 --- a/lektor.code-workspace +++ b/lektor.code-workspace @@ -20,6 +20,10 @@ "path": "src/rust/amadeus-rs", "name": "Amadeus RS Sources" }, + { + "path": "src/rust/amadeus-next", + "name": "Amadeus Next RS Sources" + }, ], "settings": { "ltex.language": "en", diff --git a/src/rust/amadeus-next/Cargo.toml b/src/rust/amadeus-next/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..c6675a90c1242b1ff3a303f580caee3e22eaa7f8 --- /dev/null +++ b/src/rust/amadeus-next/Cargo.toml @@ -0,0 +1,31 @@ +[profile.release] +strip = true +lto = true +opt-level = "s" +codegen-units = 1 + +[workspace] +resolver = "2" +members = ["amadeus", "amalib", "commons", "lkt-rs"] + +[workspace.package] +edition = "2021" +authors = ["Maƫl MARTIN"] +version = "0.1.0" +license = "MIT" + +[workspace.dependencies] +log = "0.4" +lazy_static = "1" +serde = { version = "^1", default-features = false, features = [ + "std", + "derive", +] } +tokio = { version = "1", default-features = false, features = [ + "rt", + "net", + "time", + "macros", + "sync", + "io-util", +] } diff --git a/src/rust/amadeus-next/amadeus/Cargo.toml b/src/rust/amadeus-next/amadeus/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..804d6c43ab7ba2bbacee8369ca165ef583154f5a --- /dev/null +++ b/src/rust/amadeus-next/amadeus/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "amadeus" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +log.workspace = true +serde.workspace = true +tokio.workspace = true diff --git a/src/rust/amadeus-next/amadeus/src/main.rs b/src/rust/amadeus-next/amadeus/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..f328e4d9d04c31d0d70d16d21a07d1613be9d577 --- /dev/null +++ b/src/rust/amadeus-next/amadeus/src/main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/src/rust/amadeus-next/amalib/Cargo.toml b/src/rust/amadeus-next/amalib/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..001f251e0515ce6e620d33e8ee8c836239ed6d2f --- /dev/null +++ b/src/rust/amadeus-next/amalib/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "amalib" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +log.workspace = true +serde.workspace = true +tokio.workspace = true + +commons = { path = "../commons" } diff --git a/src/rust/amadeus-next/amalib/src/connexion.rs b/src/rust/amadeus-next/amalib/src/connexion.rs new file mode 100644 index 0000000000000000000000000000000000000000..29de6762f7efd20209b47211dcef936e10982ee7 --- /dev/null +++ b/src/rust/amadeus-next/amalib/src/connexion.rs @@ -0,0 +1,298 @@ +use crate::*; +use tokio::net::TcpStream; + +pub struct LektorConnexion { + version: String, + stream: TcpStream, +} + +pub struct LektorIdleConnexion { + version: String, + stream: TcpStream, + idle_list: Vec<String>, +} + +/// Write a string into the socket. The buffer being send must be valid, +/// i.e. must not exed the [constants::LKT_MESSAGE_MAX] counting the +/// ending new line `\n`. +async fn write_string( + stream: &mut TcpStream, + buffer: impl AsRef<str>, +) -> StackedResult<(), LektorCommError> { + use LektorCommError::*; + let buffer = buffer.as_ref(); + let size = buffer.len(); + if size >= constants::LKT_MESSAGE_MAX { + err_report!(err BufferTooBig(size)) + } else if !buffer.ends_with('\n') { + err_report!(err BufferDontEndWithLF) + } else { + loop { + stream.writable().await.map_err(Io)?; + match stream.try_write(buffer.as_bytes()) { + Ok(n) if n != size => break err_report!(err IncorrectWriteSize(size, n)), + Ok(_) => break Ok(()), + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(e) => break err_report!(err Io(e)), + } + } + } +} + +/// Read replies from the lektord server. +async fn read_replies( + stream: &mut TcpStream, +) -> StackedResult<(Vec<String>, Option<usize>), LektorCommError> { + let (mut ret, mut continuation) = (Vec::new(), None); + loop { + stream + .readable().await + .map_err(|e| err_report!(LektorCommError::Io(e) => [ format!("failed to get MPD version from {:?}", stream.peer_addr()) ]))?; + let mut line = [0; constants::LKT_MESSAGE_MAX]; + match stream.try_read(&mut line) { + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(e) => return err_report!(err LektorCommError::Io(e)), + Ok(0) => { + return err_report!(err LektorCommError::Io(std::io::Error::new(std::io::ErrorKind::Other, "recieved empty line"))) + } + Ok(size) => { + let msg = std::str::from_utf8(&line[..size]) + .map_err(|e| err_report!(LektorCommError::Utf8(e) => [ format!("the lektord server at {:?} returned an invalid utf8 string", stream.peer_addr()) ]))? + .trim(); + match LektorQueryLineType::from_str(msg) { + Ok(LektorQueryLineType::Ok) => return Ok((ret, continuation)), + Ok(LektorQueryLineType::Ack) => { + return err_report!(err LektorCommError::Io(std::io::Error::from( + std::io::ErrorKind::Other + ))) + } + Ok(LektorQueryLineType::Data) => ret.push(msg.to_string()), + Ok(LektorQueryLineType::ListOk) => continue, + Ok(LektorQueryLineType::Continuation(cont)) => continuation = Some(cont), + Err(()) => { + return err_report!(err LektorCommError::QueryError => [ "unknown query line type" ]) + } + } + } + } + } +} + +/// Read replies from the lektord server. If nothing is available returns +/// nothing! This function do the same thing as [read_replies] but breaks on +/// [std::io::ErrorKind::WouldBlock]. +async fn read_maybe_replies( + stream: &mut TcpStream, +) -> StackedResult<Option<(Vec<String>, Option<usize>)>, LektorCommError> { + let (mut ret, mut continuation) = (Vec::new(), None); + loop { + stream + .readable().await + .map_err(|e| err_report!(LektorCommError::Io(e) => [ format!("failed to get MPD version from {:?}", stream.peer_addr()) ]))?; + let mut line = [0; constants::LKT_MESSAGE_MAX]; + match stream.try_read(&mut line) { + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => return Ok(None), + Err(e) => return err_report!(err LektorCommError::Io(e)), + Ok(0) => { + return err_report!(err LektorCommError::Io(std::io::Error::new(std::io::ErrorKind::Other, "recieved empty line"))) + } + Ok(size) => { + let msg = std::str::from_utf8(&line[..size]) + .map_err(|e| err_report!(LektorCommError::Utf8(e) => [ format!("the lektord server at {:?} returned an invalid utf8 string", stream.peer_addr()) ]))? + .trim(); + match LektorQueryLineType::from_str(msg) { + Ok(LektorQueryLineType::Ok) => return Ok(Some((ret, continuation))), + Ok(LektorQueryLineType::Ack) => { + return err_report!(err LektorCommError::Io(std::io::Error::from( + std::io::ErrorKind::Other + ))) + } + Ok(LektorQueryLineType::Data) => ret.push(msg.to_string()), + Ok(LektorQueryLineType::ListOk) => continue, + Ok(LektorQueryLineType::Continuation(cont)) => continuation = Some(cont), + Err(()) => { + return err_report!(err LektorCommError::QueryError => [ "unknown query line type" ]) + } + } + } + } + } +} + +declare_err_type!(pub LektorCommError { + #[error("lektord server didn't return OK code")] + MpdAck, + + #[error("got an io error: {0}")] + Io(std::io::Error), + + #[error("invalid utf8 string found: {0}")] + Utf8(std::str::Utf8Error), + + #[error("invalid buffer of length {0}: too big")] + BufferTooBig(usize), + + #[error("invalid buffer: don't end with LF `\\n`")] + BufferDontEndWithLF, + + #[error("failed to write all the buffer of size {0}, only wrote {1} bytes")] + IncorrectWriteSize(usize, usize), + + #[error("query failed validation test")] + InvalidQuery, + + #[error("failed to convert the unformated response into a formated one")] + ToFormatedResponse, + + #[error("the lektord server returned an error with the sent query(ies)")] + QueryError, +}); + +impl LektorConnexion { + /// Create a new connexion to a lektord server. If the versions mismatch we + /// log an error but continue... + pub async fn new(hostname: impl AsRef<str>, port: i16) -> StackedResult<Self, LektorCommError> { + let addr = format!("{}:{port}", hostname.as_ref()); + let stream: TcpStream = TcpStream::connect(&addr).await.map_err( + |e| err_report!(LektorCommError::Io(e) => [ format!("faild to connect to {addr}") ]), + )?; + info!( + "connected to {addr} from local address {:?}", + stream.local_addr().map_err( + |e| err_report!(LektorCommError::Io(e) => [ format!("faild to connect to {addr}") ]) + )? + ); + + let version: String = loop { + stream.readable().await.map_err(|e| err_report!(LektorCommError::Io(e) => [ format!("failed to get MPD version from {addr}") ]))?; + let mut version = [0; constants::LKT_MESSAGE_MAX]; + match stream.try_read(&mut version) { + Ok(n) => { + break std::str::from_utf8(&version[..n]) + .map_err(|e| err_report!(LektorCommError::Utf8(e) => [ format!("failed to get MPD version from {addr}") ]))? + .trim() + .to_string() + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(e) => return err_report!(err LektorCommError::Io(e)), + } + }; + + either!(version == format!("OK MPD {}", constants::MPD_VERSION) + => info!("connecting to lektord with MPD version {version}") + ; error!("got MPD version {version} from {addr}, but amalib is compatible with {}", constants::MPD_VERSION) + ); + + Ok(Self { version, stream }) + } + + pub fn version(&self) -> &str { + &self.version + } + + /// Send a query to the lektord server. + pub async fn send( + &mut self, + query: LektorQuery, + ) -> StackedResult<LektorResponse, LektorCommError> { + let mut res: Vec<String> = Vec::new(); + query + .verify() + .map_err(|e| err_report!(LektorCommError::InvalidQuery => [ e ]))?; + let builder = query.get_response_type(); + + self.send_query_inner(query, &mut res).await?; + let formated = LektorFormatedResponse::try_from(res) + .map_err(|e| err_report!(LektorCommError::QueryError => [ e ]))?; + builder(formated).map_err(|e| err_report!(LektorCommError::ToFormatedResponse => [ e ])) + } + + /// The inner helper function to send queries to the lektord server. + async fn send_query_inner( + &mut self, + query: LektorQuery, + previous_ret: &mut Vec<String>, + ) -> StackedResult<(), LektorCommError> { + write_string(&mut self.stream, query.to_string()).await?; + loop { + match read_replies(&mut self.stream).await { + Err(e) => return Err(e), + Ok((res, _)) if res.is_empty() => return Ok(()), + Ok((res, None)) => { + previous_ret.extend(res); + return Ok(()); + } + Ok((res, Some(cont))) => { + previous_ret.extend(res); + write_string( + &mut self.stream, + LektorQuery::create_continuation(query.clone(), cont).to_string(), + ) + .await?; + } + } + } + } +} + +impl LektorIdleConnexion { + pub async fn idle( + connexion: LektorConnexion, + idle_list: impl IntoIterator<Item = LektorIdleNotification>, + ) -> StackedResult<Self, LektorCommError> { + let idle_list: Vec<_> = idle_list + .into_iter() + .map(|notification| format!("{notification}")) + .collect(); + let idle_list_buffer = format!("idle {}\n", idle_list.join(" ")); + let LektorConnexion { + version, + mut stream, + } = connexion; + + write_string(&mut stream, idle_list_buffer).await?; + + let (mut reply, _) = read_replies(&mut stream).await?; + either!(reply.is_empty() + => Ok(Self { version, stream, idle_list }) + ; { + reply.iter_mut().for_each(|msg| msg.insert_str(0, " - ")); + reply.insert(0, "failed to idle the connexion, got the following messages from the server:".to_string()); + err_report!(err LektorCommError::MpdAck => reply) + } + ) + } + + pub async fn get_notifications(&mut self) -> Vec<LektorIdleNotification> { + match read_maybe_replies(&mut self.stream).await { + Ok(None) => vec![], + Ok(Some((notifications, _))) => notifications + .iter() + .filter_map(|msg| msg.strip_prefix("changed: ")) + .flat_map(|notifications| notifications.split(&[',', ' '][..])) + .filter_map( + |notification| match LektorIdleNotification::try_from(notification) { + Ok(notification) => Some(notification), + Err(()) => { + error!("the notification `{notification}` is invalid..."); + None + } + }, + ) + .collect(), + Err(e) => { + let e = err_attach!(err_ctx!(e => LektorCommError::MpdAck) => [ "failed to read idle replies" ]); + error!("{e}"); + vec![] + } + } + } + + pub fn version(&self) -> &str { + &self.version + } + + pub fn idle_list(&self) -> &[String] { + &self.idle_list + } +} diff --git a/src/rust/amadeus-next/amalib/src/constants.rs b/src/rust/amadeus-next/amalib/src/constants.rs new file mode 100644 index 0000000000000000000000000000000000000000..505f0af8e8e8bc5773341ca8fc6496988cb76508 --- /dev/null +++ b/src/rust/amadeus-next/amalib/src/constants.rs @@ -0,0 +1,22 @@ +//! Contains standard constants from lektord, must be updated manually if they +//! change in lektord... + +#![allow(dead_code)] + +/// MPD commands are at most 32 character long. +pub(crate) const LKT_COMMAND_LEN_MAX: usize = 32; + +/// At most 32 words in a command are supported. +pub(crate) const LKT_MESSAGE_ARGS_MAX: usize = 32; + +/// A message is at most <defined> chars long +pub(crate) const LKT_MESSAGE_MAX: usize = 2048; + +/// At most 64 commands per client. +pub(crate) const COMMAND_LIST_MAX: usize = 64; + +/// At most 16 messages per client. +pub(crate) const BUFFER_OUT_MAX: usize = 16; + +/// Expected version from the lektord daemin. +pub(crate) const MPD_VERSION: &str = "0.21.16"; diff --git a/src/rust/amadeus-next/amalib/src/lib.rs b/src/rust/amadeus-next/amalib/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..aef81465077bb16212f1578f62a55c0bf1770516 --- /dev/null +++ b/src/rust/amadeus-next/amalib/src/lib.rs @@ -0,0 +1,98 @@ +//! The base library used for lektor's clients. It is composed of elements to +//! communicate with the lektord server and elements to store and organise the +//! queried informations. + +mod connexion; +mod constants; +mod query; +mod response; +mod uri; + +pub use connexion::*; +pub use query::*; +pub use response::*; +pub use uri::*; + +pub(crate) use commons::*; +pub(crate) use log::*; +pub(crate) use std::str::FromStr; + +/// The playback state of the lektord server. +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq, Copy)] +pub enum LektorState { + Stopped, + Play(usize), + Pause(usize), +} + +impl Default for LektorState { + fn default() -> Self { + Self::Stopped + } +} + +/// The possible notifications that an idle connexion can listen to. +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq, Copy)] +pub enum LektorIdleNotification { + Database, + Update, + StoredPlaylist, + Playlist, + Player, + Mixer, + Output, + Options, + Partition, + Sticker, + Subscription, + Message, +} + +impl std::fmt::Display for LektorIdleNotification { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(<LektorIdleNotification as AsRef<str>>::as_ref(self)) + } +} + +impl TryFrom<&str> for LektorIdleNotification { + type Error = (); + + fn try_from(value: &str) -> Result<Self, Self::Error> { + use LektorIdleNotification::*; + Ok(match value { + "database" => Database, + "update" => Update, + "stored_playlist" => StoredPlaylist, + "playlist" => Playlist, + "player" => Player, + "mixer" => Mixer, + "output" => Output, + "options" => Options, + "partition" => Partition, + "sticker" => Sticker, + "subscription" => Subscription, + "message" => Message, + _ => return Err(()), + }) + } +} + +impl AsRef<str> for LektorIdleNotification { + fn as_ref(&self) -> &str { + use LektorIdleNotification::*; + match self { + Database => "database", + Update => "update", + StoredPlaylist => "stored_playlist", + Playlist => "playlist", + Player => "player", + Mixer => "mixer", + Output => "output", + Options => "options", + Partition => "partition", + Sticker => "sticker", + Subscription => "subscription", + Message => "message", + } + } +} diff --git a/src/rust/amadeus-next/amalib/src/query.rs b/src/rust/amadeus-next/amalib/src/query.rs new file mode 100644 index 0000000000000000000000000000000000000000..bae0d763e9fa370f3db1c5e4827469352a511efd --- /dev/null +++ b/src/rust/amadeus-next/amalib/src/query.rs @@ -0,0 +1,185 @@ +//! Contains files to create and build queries to send latter to lektord. + +use crate::*; +use std::string::ToString; + +pub(crate) enum LektorQueryLineType { + Ok, + ListOk, + Ack, + Continuation(usize), + Data, +} + +#[derive(Debug, Clone)] +pub enum LektorQuery { + Ping, + Close, + KillServer, + ConnectAsUser(String, Box<LektorQuery>), + + CurrentKara, + PlaybackStatus, + + PlayNext, + PlayPrevious, + ShuffleQueue, + + ListAllPlaylists, + ListPlaylist(String), + SearchKara(LektorUri), + + FindAddKara(LektorUri), + InsertKara(LektorUri), + AddKara(LektorUri), + + Continuation(usize, Box<LektorQuery>), +} + +impl std::str::FromStr for LektorQueryLineType { + type Err = (); + + fn from_str(line: &str) -> Result<Self, Self::Err> { + if Self::is_line_ok(line) { + Ok(Self::Ok) + } else if Self::is_line_ack(line) { + Ok(Self::Ack) + } else if Self::is_line_list_ok(line) { + Ok(Self::ListOk) + } else if let Some(cont) = Self::is_line_continuation(line) { + Ok(Self::Continuation(cont)) + } else { + Ok(Self::Data) + } + } +} + +impl LektorQueryLineType { + fn is_line_continuation(line: &str) -> Option<usize> { + if !line.starts_with("continue:") { + return None; + } + match line.trim_start_matches("continue:").trim().parse::<usize>() { + Ok(cont) => Some(cont), + Err(_) => None, + } + } + + fn is_line_list_ok(line: &str) -> bool { + (line == "list_OK\n") || (line == "list_OK") + } + + fn is_line_ok(line: &str) -> bool { + (line == "OK\n") || (line == "OK") + } + + fn is_line_ack(line: &str) -> bool { + line.starts_with("ACK: ") + } +} + +/// The type of the function to use to produce a typed response from the +/// formated response. +type QueryToTypeResponseBuilder = fn(LektorFormatedResponse) -> Result<LektorResponse, String>; + +impl LektorQuery { + /// Get the function to use to produce the typed response from the formated + /// response, to automatically create the correct thing. + pub fn get_response_type(&self) -> QueryToTypeResponseBuilder { + use LektorQuery::*; + match self { + Ping | Close | KillServer | PlayNext | PlayPrevious | ShuffleQueue | InsertKara(_) + | AddKara(_) => LektorEmptyResponse::from_formated, + + ListAllPlaylists => LektorPlaylistSetResponse::from_formated, + PlaybackStatus => LektorPlaybackStatusResponse::from_formated, + CurrentKara => LektorCurrentKaraResponse::from_formated, + + ConnectAsUser(_, cmd) | Continuation(_, cmd) => cmd.get_response_type(), + + ListPlaylist(_) | SearchKara(_) | FindAddKara(_) => { + LektorKaraSetResponse::from_formated + } + } + } + + /// Create a continued query out of another one. If the query is already a + /// continuation query then the underlying query is reused. + pub fn create_continuation(query: Self, cont: usize) -> Self { + match query { + Self::Continuation(_, query) => Self::Continuation(cont, query), + _ => Self::Continuation(cont, Box::new(query)), + } + } + + /// Verify that a query is Ok. + pub fn verify(&self) -> Result<(), String> { + use LektorQuery::*; + match self { + // User commands + SearchKara(_) | FindAddKara(_) | InsertKara(_) | AddKara(_) | PlaybackStatus + | PlayNext | PlayPrevious | ShuffleQueue | ListAllPlaylists | ListPlaylist(_) + | CurrentKara | Ping => Ok(()), + + // Should be admin commands + Close => Err("close is an admin command".to_string()), + KillServer => Err("kill server is an admin command".to_string()), + + // Admin commands + ConnectAsUser(_, cmd) => match cmd.as_ref() { + Close | KillServer => Ok(()), + _ => Err(format!("not an admin command: {cmd:?}")), + }, + + // Continuation commands + Continuation(_, cmd) => match cmd.as_ref() { + ListAllPlaylists | FindAddKara(_) | SearchKara(_) | ListPlaylist(_) => Ok(()), + _ => Err(format!("not a continuable command: {cmd:?}")), + }, + } + } +} + +impl std::fmt::Display for LektorQuery { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + macro_rules! lkt_str { + ($lit:literal) => { + f.write_str(concat!($lit, '\n')) + }; + } + + use LektorQuery::*; + match self { + Ping => lkt_str!("ping"), + Close => lkt_str!("close"), + KillServer => lkt_str!("kill"), + ConnectAsUser(password, cmd) => write!( + f, + concat!( + "command_list_ok_begin\n", + "password {}\n", + "{}\n", + "command_list_end\n", + ), + password, cmd + ), + + CurrentKara => lkt_str!("currentsong"), + PlaybackStatus => lkt_str!("status"), + + PlayNext => lkt_str!("next"), + PlayPrevious => lkt_str!("previous"), + ShuffleQueue => lkt_str!("shuffle"), + + ListAllPlaylists => lkt_str!("listplaylists"), + ListPlaylist(plt_name) => writeln!(f, "listplaylist {plt_name}"), + SearchKara(uri) => writeln!(f, "find {uri}"), + + FindAddKara(uri) => writeln!(f, "findadd {uri}"), + InsertKara(uri) => writeln!(f, "__insert {uri}"), + AddKara(uri) => writeln!(f, "add {uri}"), + + Continuation(cont, query) => write!(f, "{cont} {query}"), + } + } +} diff --git a/src/rust/amadeus-next/amalib/src/response.rs b/src/rust/amadeus-next/amalib/src/response.rs new file mode 100644 index 0000000000000000000000000000000000000000..75e53f74543e5d171b2cca43f42b0a6de6101d81 --- /dev/null +++ b/src/rust/amadeus-next/amalib/src/response.rs @@ -0,0 +1,338 @@ +//! Contains types for typed response. + +use crate::*; + +/// A formated response is just a list of key/pairs. We get every line that is +/// not Ok/Ack/Continue (i.e. data lines) and split on the first ':' and trim +/// spaces from the keys and the values. The keys are always in lowercase. +#[derive(Debug)] +pub struct LektorFormatedResponse { + content: Vec<(String, String)>, + raw_content: Vec<String>, +} + +impl LektorFormatedResponse { + /// Pop the first key found in the response, get an error if the key is not + /// found. If multiple keys are found, only the first found is returned. + pub fn pop(&mut self, key: &str) -> Result<String, String> { + match self + .content + .iter() + .enumerate() + .find_map(|(index, (what, _))| (what == key).then_some(index)) + { + Some(index) => Ok(self.content.remove(index).1), + None => Err(format!("no key {key} was found in formated response")), + } + } + + /// Pop all the entries with the said key. If no key is found then the empty + /// vector is returned. This function can't fail. + pub fn pop_all(&mut self, key: &str) -> Vec<String> { + let mut ret: Vec<String> = Vec::new(); + self.content.retain(|(what, field)| { + if *what == key { + ret.push(field.clone()); + false + } else { + true + } + }); + ret + } + + /// Get the raw content of the response. + pub fn pop_raw(self) -> Vec<String> { + self.raw_content + } +} + +impl IntoIterator for LektorFormatedResponse { + type Item = (String, String); + type IntoIter = + <std::vec::Vec<(std::string::String, std::string::String)> as IntoIterator>::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.content.into_iter() + } +} + +impl TryFrom<Vec<String>> for LektorFormatedResponse { + type Error = String; + + fn try_from(vec: Vec<String>) -> Result<Self, Self::Error> { + let (mut content, mut raw_content) = (Vec::new(), Vec::new()); + for line in vec { + match line.splitn(2, ':').collect::<Vec<&str>>()[..] { + [key, value] => content.push((key.trim().to_lowercase(), value.trim().to_string())), + _ => raw_content.push(line), + } + } + Ok(Self { + content, + raw_content, + }) + } +} + +#[derive(Debug)] +pub enum LektorResponse { + PlaybackStatus(LektorPlaybackStatusResponse), + CurrentKara(LektorCurrentKaraResponse), + PlaylistSet(LektorPlaylistSetResponse), + EmptyResponse(LektorEmptyResponse), + KaraSet(LektorKaraSetResponse), +} + +/// A trait for typed lektor responses. Such responses must be built by +/// consuming a formated response. We also protect from implemeting this trait +/// outside of this crate. +pub trait FromLektorResponse: std::fmt::Debug + private::Sealed { + /// Consume a formated response to produce the correctly typed response. May + /// got an error as a string that describes the problem. + fn from_formated(response: LektorFormatedResponse) -> Result<LektorResponse, String> + where + Self: Sized; +} + +mod private { + use super::*; + pub trait Sealed {} + impl Sealed for LektorPlaybackStatusResponse {} + impl Sealed for LektorCurrentKaraResponse {} + impl Sealed for LektorPlaylistSetResponse {} + impl Sealed for LektorKaraSetResponse {} + impl Sealed for LektorEmptyResponse {} +} + +macro_rules! getter { + ($name: ident: ref $type: ty) => { + pub fn $name(&self) -> &$type { + &self.$name + } + }; + + ($name: ident: $type: ty) => { + pub fn $name(&self) -> $type { + self.$name + } + }; +} + +#[derive(Debug)] +pub struct LektorPlaybackStatusResponse { + elapsed: usize, + songid: Option<usize>, + song: Option<usize>, + volume: usize, + state: LektorState, + duration: usize, + updating_db: usize, + playlistlength: usize, + random: bool, + consume: bool, + single: bool, + repeat: bool, +} + +#[derive(Debug)] +pub struct LektorPlaylistSetResponse { + playlists: Vec<String>, +} + +#[derive(Debug)] +pub struct LektorCurrentKaraInnerResponse { + title: String, + author: String, + source: String, + song_type: String, + song_number: Option<usize>, + category: String, + language: String, +} + +#[derive(Debug)] +pub struct LektorKaraSetResponse { + karas: Vec<String>, +} + +#[derive(Debug)] +pub struct LektorCurrentKaraResponse { + content: Option<LektorCurrentKaraInnerResponse>, +} + +#[derive(Debug)] +pub struct LektorEmptyResponse; + +impl LektorCurrentKaraResponse { + getter!(content: ref Option<LektorCurrentKaraInnerResponse>); + + pub fn maybe_into_inner(self) -> Option<LektorCurrentKaraInnerResponse> { + self.content + } + + pub fn into_inner(self) -> LektorCurrentKaraInnerResponse { + self.content.unwrap() + } +} + +impl LektorCurrentKaraInnerResponse { + getter!(title: ref String); + getter!(author: ref String); + getter!(source: ref String); + getter!(song_type: ref String); + getter!(song_number: Option<usize>); + getter!(category: ref String); + getter!(language: ref String); +} + +impl LektorPlaybackStatusResponse { + getter!(elapsed: usize); + getter!(songid: Option<usize>); + getter!(song: Option<usize>); + getter!(volume: usize); + getter!(state: LektorState); + getter!(duration: usize); + getter!(updating_db: usize); + getter!(playlistlength: usize); + getter!(random: bool); + getter!(consume: bool); + getter!(single: bool); + getter!(repeat: bool); + + pub fn verify(&self) -> bool { + self.elapsed() <= self.duration() + } +} + +impl LektorKaraSetResponse { + pub fn iter(&self) -> &[String] { + &self.karas[..] + } +} + +impl LektorPlaylistSetResponse { + pub fn iter(&self) -> &[String] { + &self.playlists[..] + } +} + +impl IntoIterator for LektorKaraSetResponse { + type Item = String; + type IntoIter = <std::vec::Vec<std::string::String> as IntoIterator>::IntoIter; + fn into_iter(self) -> Self::IntoIter { + self.karas.into_iter() + } +} + +impl IntoIterator for LektorPlaylistSetResponse { + type Item = String; + type IntoIter = <std::vec::Vec<std::string::String> as IntoIterator>::IntoIter; + fn into_iter(self) -> Self::IntoIter { + self.playlists.into_iter() + } +} + +impl LektorCurrentKaraInnerResponse { + /// If the response is partial we might want to return none (if we are not + /// playing anything for example...) + pub(self) fn is_partial(&self) -> bool { + self.title.is_empty() + || self.author.is_empty() + || self.source.is_empty() + || self.song_type.is_empty() + || self.category.is_empty() + || self.language.is_empty() + } +} + +impl FromLektorResponse for LektorEmptyResponse { + fn from_formated(_: LektorFormatedResponse) -> Result<LektorResponse, String> { + Ok(LektorResponse::EmptyResponse(Self {})) + } +} + +impl FromLektorResponse for LektorPlaylistSetResponse { + fn from_formated(mut response: LektorFormatedResponse) -> Result<LektorResponse, String> { + Ok(LektorResponse::PlaylistSet(Self { + playlists: response.pop_all("name"), + })) + } +} + +impl FromLektorResponse for LektorKaraSetResponse { + fn from_formated(response: LektorFormatedResponse) -> Result<LektorResponse, String> + where + Self: Sized, + { + Ok(LektorResponse::KaraSet(Self { + karas: response.pop_raw(), + })) + } +} + +impl FromLektorResponse for LektorPlaybackStatusResponse { + fn from_formated(mut response: LektorFormatedResponse) -> Result<LektorResponse, String> { + let mut ret = Self { + elapsed: response.pop("elapsed")?.parse::<usize>().unwrap_or(0), + songid: match response.pop("songid")?.parse::<isize>() { + Ok(x) if x <= 0 => None, + Ok(x) => Some(x as usize), + Err(_) => None, + }, + song: match response.pop("song")?.parse::<usize>() { + Ok(x) => Some(x), + Err(_) => None, + }, + volume: response + .pop("volume")? + .parse::<usize>() + .unwrap() + .clamp(0, 100), + duration: response.pop("duration")?.parse::<usize>().unwrap(), + updating_db: response.pop("updating_db")?.parse::<usize>().unwrap(), + playlistlength: response.pop("playlistlength")?.parse::<usize>().unwrap(), + random: response.pop("random")?.parse::<usize>().unwrap() != 0, + consume: response.pop("consume")?.parse::<usize>().unwrap() != 0, + single: response.pop("single")?.parse::<usize>().unwrap() != 0, + repeat: response.pop("repeat")?.parse::<usize>().unwrap() != 0, + state: LektorState::Stopped, + }; + ret.state = match &response.pop("state")?[..] { + "play" => LektorState::Play(ret.songid.unwrap()), + "pause" => LektorState::Pause(ret.songid.unwrap()), + _ => LektorState::Stopped, + }; + Ok(LektorResponse::PlaybackStatus(ret)) + } +} + +impl FromLektorResponse for LektorCurrentKaraResponse { + fn from_formated(mut response: LektorFormatedResponse) -> Result<LektorResponse, String> { + let song_type_number = response.pop("type")?; + let (song_type, song_number) = match song_type_number.find(char::is_numeric) { + Some(index) => ( + song_type_number[..index].to_owned(), + match song_type_number[index..].parse::<usize>() { + Ok(x) => Some(x), + Err(_) => None, + }, + ), + None => panic!("Oupsy"), + }; + + let inner = LektorCurrentKaraInnerResponse { + title: response.pop("title")?, + author: response.pop("author")?, + source: response.pop("source")?, + category: response.pop("category")?, + language: response.pop("language")?, + song_type, + song_number, + }; + + Ok(LektorResponse::CurrentKara(Self { + content: (!inner.is_partial()).then_some(inner), + })) + } +} diff --git a/src/rust/amadeus-next/amalib/src/uri.rs b/src/rust/amadeus-next/amalib/src/uri.rs new file mode 100644 index 0000000000000000000000000000000000000000..4d4bf040ce37ddad476d4520c227ff42c5d16859 --- /dev/null +++ b/src/rust/amadeus-next/amalib/src/uri.rs @@ -0,0 +1,36 @@ +//! Build lektord queries. + +use crate::*; + +#[derive(Debug, Clone)] +pub enum LektorUri { + Id(i32), + Author(String), + Playlist(String), + Query(Vec<String>), +} + +impl std::fmt::Display for LektorUri { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + 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) => { + 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(" ")) + } + }; + + const MAX_CHARS: usize = crate::constants::LKT_MESSAGE_MAX - 1; + if ret_str.len() > MAX_CHARS { + warn!("the uri will be truncated to {MAX_CHARS} characters, note that it can be further truncated"); + ret_str = ret_str[..=MAX_CHARS].to_string(); + } + + f.write_str(&ret_str) + } +} diff --git a/src/rust/amadeus-next/commons/Cargo.toml b/src/rust/amadeus-next/commons/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..a76970b4340f88edf0b23d3b1a0abf509af3bb64 --- /dev/null +++ b/src/rust/amadeus-next/commons/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "commons" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +log.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/error.rs b/src/rust/amadeus-next/commons/src/error.rs new file mode 100644 index 0000000000000000000000000000000000000000..4802dfedbf10a7c69a703ec1e04623b004e6937a --- /dev/null +++ b/src/rust/amadeus-next/commons/src/error.rs @@ -0,0 +1,119 @@ +pub use error_stack::{ + report, Context as StackedContext, Report, Result as StackedResult, + ResultExt as StackedResultExt, +}; +pub use thiserror; +pub use thiserror::{Error as ThisError, __private::DisplayAsDisplay}; + +/// Create an error report. Be sure to have the correct elements from +/// `error_stack` are imported into the current scope. If you use the +/// 'attachement' variant, they will be printed in the order you specified (i.e. +/// they will be pushed in the reverse order) +/// +/// For example: +/// ```no_run +/// err_report!(ConnexionError::IoError => [ "Some precisions" ]) +/// ``` +/// ```no_run +/// return err_report!(PassError::NotImplemented); +/// ``` +#[macro_export] +macro_rules! err_report { + (err $expr: expr) => { + Err($crate::report!($expr)) + }; + + (err $expr: expr => $attachements: expr) => { + $attachements + .into_iter() + .fold(Err(report!($expr)), |err, attachement| { + err.attach_printable(attachement) + }) + }; + + ($expr: expr) => { + $crate::report!($expr) + }; + + ($expr: expr => $attachements: expr) => { + $attachements + .into_iter() + .fold(report!($expr), |err, attachement| { + err.attach_printable(attachement) + }) + }; +} + +/// Attach more things to the error +#[macro_export] +macro_rules! err_attach { + ($error: expr => $attachements: expr) => { + $attachements + .into_iter() + .fold($error, |err, attachement| err.attach_printable(attachement)) + }; +} + +/// Create a new error report, initialized with no error in it. +#[macro_export] +macro_rules! err_init { + ($ty: ident) => { + Option::<Report<$ty>>::None + }; +} + +/// Return the stacked errors if some are found. +#[macro_export] +macro_rules! err_return { + ($error: expr) => { + if let Some(error) = $error { + return Err(error); + } + }; +} + +/// Append an error to the stack. +#[macro_export] +macro_rules! err_append { + ($error: expr => $err: expr) => {{ + err_append!($error, $err) + }}; + + ($error: expr, $err: expr) => {{ + if let Some(error) = $error.as_mut() { + error.extend_one($err); + } else { + $error = Some($err); + } + }}; +} + +#[macro_export] +#[rustfmt::skip] +macro_rules! err_ctx { + ($error: expr => $ctx: expr) => { $error.change_context($ctx) }; + ($error: expr, $ctx: expr) => { $error.change_context($ctx) }; + + (err $error: expr => $ctx: expr) => { Err($error.change_context($ctx)) }; + (err $error: expr, $ctx: expr) => { Err($error.change_context($ctx)) }; +} + +/// Declare a new error variant type to be used with reports and the +/// [`crate::err_report`] macro. You must declare a closure to implement the fmt function +/// from [`std::fmt::Display`]. You may use this macro like: +/// ```no_run +/// declare_err_type!(pub(crate) CliArgumentParsingError { +/// #[error("unknown command")] +/// UnknownCommand(#[passed] String), +/// UnknownFileType, +/// UnknownOption, +/// }); +/// ``` +#[macro_export] +macro_rules! declare_err_type { + ($vis: vis $enum: ident $variants: tt) => { + #[derive(std::fmt::Debug, $crate::ThisError)] + #[allow(clippy::enum_variant_names, missing_docs)] + $vis enum $enum $variants + }; +} diff --git a/src/rust/amadeus-next/commons/src/lib.rs b/src/rust/amadeus-next/commons/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..3c4eeed704578339984eb952b5a03b4a678359b1 --- /dev/null +++ b/src/rust/amadeus-next/commons/src/lib.rs @@ -0,0 +1,4 @@ +mod error; +mod macros; + +pub use error::*; diff --git a/src/rust/amadeus-next/commons/src/macros.rs b/src/rust/amadeus-next/commons/src/macros.rs new file mode 100644 index 0000000000000000000000000000000000000000..e8b692585346eeb3ef456b02817a4582e9735403 --- /dev/null +++ b/src/rust/amadeus-next/commons/src/macros.rs @@ -0,0 +1,10 @@ +#[macro_export] +macro_rules! either { + ($test:expr => $true_expr:expr; $false_expr:expr) => { + if $test { + $true_expr + } else { + $false_expr + } + }; +} diff --git a/src/rust/amadeus-next/lkt-rs/Cargo.toml b/src/rust/amadeus-next/lkt-rs/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..e2902802689ce6f8e27cf6a45d4291f3fe88cc9d --- /dev/null +++ b/src/rust/amadeus-next/lkt-rs/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "lkt-rs" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +log.workspace = true +serde.workspace = true diff --git a/src/rust/amadeus-next/lkt-rs/src/main.rs b/src/rust/amadeus-next/lkt-rs/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..f328e4d9d04c31d0d70d16d21a07d1613be9d577 --- /dev/null +++ b/src/rust/amadeus-next/lkt-rs/src/main.rs @@ -0,0 +1 @@ +fn main() {}