From f21b0941a16dc24a16abe9d5d236308f2426e96e Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 12 Jun 2025 17:58:11 +0300 Subject: [PATCH 1/8] cli: remove redundant import --- .envrc | 2 +- src/cli.rs | 22 ++++--- src/lib.rs | 164 +++++++++++++++++++++++++++----------------------- src/server.rs | 37 +++++++----- 4 files changed, 127 insertions(+), 98 deletions(-) diff --git a/.envrc b/.envrc index 3550a30f..23f9a4ef 100644 --- a/.envrc +++ b/.envrc @@ -1 +1 @@ -use flake +use flake . --substituters "https://cache.nixos.org" diff --git a/src/cli.rs b/src/cli.rs index b27ddb0b..2634a6b9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,12 +1,14 @@ use clap::{Parser, Subcommand}; -use mrc::set_property; use mrc::SOCKET_PATH; use mrc::{ - get_property, loadfile, playlist_clear, playlist_move, playlist_next, playlist_prev, - playlist_remove, quit, seek, MrcError, Result, + MrcError, Result, get_property, loadfile, playlist_clear, playlist_move, playlist_next, + playlist_prev, playlist_remove, quit, seek, set_property, }; use serde_json::json; -use std::{io::{self, Write}, path::PathBuf}; +use std::{ + io::{self, Write}, + path::PathBuf, +}; use tracing::{debug, error, info}; #[derive(Parser)] @@ -165,8 +167,8 @@ async fn main() -> Result<()> { CommandOptions::List => { info!("Listing playlist items"); if let Some(data) = get_property("playlist", None).await? { - let pretty_json = serde_json::to_string_pretty(&data) - .map_err(MrcError::ParseError)?; + let pretty_json = + serde_json::to_string_pretty(&data).map_err(MrcError::ParseError)?; println!("{}", pretty_json); } } @@ -212,7 +214,9 @@ async fn main() -> Result<()> { print!("mpv> "); stdout.flush().map_err(MrcError::ConnectionError)?; let mut input = String::new(); - stdin.read_line(&mut input).map_err(MrcError::ConnectionError)?; + stdin + .read_line(&mut input) + .map_err(MrcError::ConnectionError)?; let trimmed = input.trim(); if trimmed.eq_ignore_ascii_case("exit") { @@ -338,7 +342,9 @@ async fn main() -> Result<()> { _ => { println!("Unknown command: {}", trimmed); - println!("Valid commands: play , pause, stop, next, prev, seek , clear, list, add , get , set , help, exit"); + println!( + "Valid commands: play , pause, stop, next, prev, seek , clear, list, add , get , set , help, exit" + ); } } } diff --git a/src/lib.rs b/src/lib.rs index 17138163..64d6eed5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -40,7 +40,7 @@ //! //! ## Functions -use serde_json::{json, Value}; +use serde_json::{Value, json}; use std::io; use thiserror::Error; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -55,43 +55,43 @@ pub enum MrcError { /// Connection to the MPV socket could not be established. #[error("failed to connect to MPV socket: {0}")] ConnectionError(#[from] io::Error), - + /// Error when parsing a JSON response from MPV. #[error("failed to parse JSON response: {0}")] ParseError(#[from] serde_json::Error), - + /// Error when a socket operation times out. #[error("socket operation timed out after {0} seconds")] SocketTimeout(u64), - + /// Error when MPV returns an error response. #[error("MPV error: {0}")] MpvError(String), - + /// Error when trying to use a property that doesn't exist. #[error("property '{0}' not found")] PropertyNotFound(String), - + /// Error when the socket response is not valid UTF-8. #[error("invalid UTF-8 in socket response: {0}")] InvalidUtf8(#[from] std::string::FromUtf8Error), - + /// Error when a network operation fails. #[error("network error: {0}")] NetworkError(String), - + /// Error when the server connection is lost or broken. #[error("server connection lost: {0}")] ConnectionLost(String), - + /// Error when a communication protocol is violated. #[error("protocol error: {0}")] ProtocolError(String), - + /// Error when invalid input is provided. #[error("invalid input: {0}")] InvalidInput(String), - + /// Error related to TLS operations. #[error("TLS error: {0}")] TlsError(String), @@ -100,6 +100,81 @@ pub enum MrcError { /// A specialized Result type for MRC operations. pub type Result = std::result::Result; +/// Connects to the MPV IPC socket with timeout. +async fn connect_to_socket(socket_path: &str) -> Result { + debug!("Connecting to socket at {}", socket_path); + + tokio::time::timeout( + std::time::Duration::from_secs(5), + UnixStream::connect(socket_path), + ) + .await + .map_err(|_| MrcError::SocketTimeout(5))? + .map_err(MrcError::ConnectionError) +} + +/// Sends a command message to the socket with timeout. +async fn send_message(socket: &mut UnixStream, command: &str, args: &[Value]) -> Result<()> { + let mut command_array = vec![json!(command)]; + command_array.extend_from_slice(args); + let message = json!({ "command": command_array }); + let message_str = format!("{}\n", serde_json::to_string(&message)?); + + debug!("Serialized message to send with newline: {}", message_str); + + // Write with timeout + tokio::time::timeout( + std::time::Duration::from_secs(5), + socket.write_all(message_str.as_bytes()), + ) + .await + .map_err(|_| MrcError::SocketTimeout(5))??; + + // Flush with timeout + tokio::time::timeout(std::time::Duration::from_secs(5), socket.flush()) + .await + .map_err(|_| MrcError::SocketTimeout(5))??; + + debug!("Message sent and flushed"); + Ok(()) +} + +/// Reads and parses the response from the socket. +async fn read_response(socket: &mut UnixStream) -> Result { + let mut response = vec![0; 1024]; + + // Read with timeout + let n = tokio::time::timeout( + std::time::Duration::from_secs(5), + socket.read(&mut response), + ) + .await + .map_err(|_| MrcError::SocketTimeout(5))??; + + if n == 0 { + return Err(MrcError::ConnectionLost( + "Socket closed unexpectedly".into(), + )); + } + + let response_str = String::from_utf8(response[..n].to_vec())?; + debug!("Raw response: {}", response_str); + + let json_response = + serde_json::from_str::(&response_str).map_err(MrcError::ParseError)?; + + debug!("Parsed IPC response: {:?}", json_response); + + // Check if MPV returned an error + if let Some(error) = json_response.get("error").and_then(|e| e.as_str()) { + if !error.is_empty() { + return Err(MrcError::MpvError(error.to_string())); + } + } + + Ok(json_response) +} + /// Sends a generic IPC command to the specified socket and returns the parsed response data. /// /// # Arguments @@ -123,71 +198,10 @@ pub async fn send_ipc_command( command, args ); - // Add timeout for connection - let stream = tokio::time::timeout( - std::time::Duration::from_secs(5), - UnixStream::connect(socket_path), - ) - .await - .map_err(|_| MrcError::SocketTimeout(5))? - .map_err(MrcError::ConnectionError)?; + let mut socket = connect_to_socket(socket_path).await?; + send_message(&mut socket, command, args).await?; + let json_response = read_response(&mut socket).await?; - let mut socket = stream; - debug!("Connected to socket at {}", socket_path); - - let mut command_array = vec![json!(command)]; - command_array.extend_from_slice(args); - let message = json!({ "command": command_array }); - let message_str = format!("{}\n", serde_json::to_string(&message)?); - debug!("Serialized message to send with newline: {}", message_str); - - // Write with timeout - tokio::time::timeout( - std::time::Duration::from_secs(5), - socket.write_all(message_str.as_bytes()), - ) - .await - .map_err(|_| MrcError::SocketTimeout(5))??; - - // Flush with timeout - tokio::time::timeout( - std::time::Duration::from_secs(5), - socket.flush(), - ) - .await - .map_err(|_| MrcError::SocketTimeout(5))??; - - debug!("Message sent and flushed"); - - let mut response = vec![0; 1024]; - - // Read with timeout - let n = tokio::time::timeout( - std::time::Duration::from_secs(5), - socket.read(&mut response), - ) - .await - .map_err(|_| MrcError::SocketTimeout(5))??; - - if n == 0 { - return Err(MrcError::ConnectionLost("Socket closed unexpectedly".into())); - } - - let response_str = String::from_utf8(response[..n].to_vec())?; - debug!("Raw response: {}", response_str); - - let json_response = serde_json::from_str::(&response_str) - .map_err(MrcError::ParseError)?; - - debug!("Parsed IPC response: {:?}", json_response); - - // Check if MPV returned an error - if let Some(error) = json_response.get("error").and_then(|e| e.as_str()) { - if !error.is_empty() { - return Err(MrcError::MpvError(error.to_string())); - } - } - Ok(json_response.get("data").cloned()) } diff --git a/src/server.rs b/src/server.rs index c307c47c..3c0dd01a 100644 --- a/src/server.rs +++ b/src/server.rs @@ -9,7 +9,10 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio_native_tls::TlsAcceptor; use tracing::{debug, error, info}; -use mrc::{get_property, playlist_clear, playlist_next, playlist_prev, quit, seek, set_property, MrcError, Result as MrcResult}; +use mrc::{ + MrcError, Result as MrcResult, get_property, playlist_clear, playlist_next, playlist_prev, + quit, seek, set_property, +}; #[derive(Parser)] #[command(author, version, about)] @@ -27,11 +30,15 @@ async fn handle_connection( stream: tokio::net::TcpStream, acceptor: Arc, ) -> MrcResult<()> { - let mut stream = acceptor.accept(stream).await + let mut stream = acceptor + .accept(stream) + .await .map_err(|e| MrcError::TlsError(e.to_string()))?; let mut buffer = vec![0; 2048]; - let n = stream.read(&mut buffer).await + let n = stream + .read(&mut buffer) + .await .map_err(MrcError::ConnectionError)?; let request = String::from_utf8_lossy(&buffer[..n]); @@ -143,15 +150,18 @@ async fn process_command(command: &str) -> MrcResult { info!("Listing playlist items"); match get_property("playlist", None).await { Ok(Some(data)) => { - let pretty_json = serde_json::to_string_pretty(&data) - .map_err(MrcError::ParseError)?; + let pretty_json = + serde_json::to_string_pretty(&data).map_err(MrcError::ParseError)?; Ok(format!("Playlist: {}", pretty_json)) - }, + } Ok(None) => Err(MrcError::PropertyNotFound("playlist".to_string())), Err(e) => Err(e), } } - _ => Err(MrcError::InvalidInput(format!("Unknown command: {}", command))), + _ => Err(MrcError::InvalidInput(format!( + "Unknown command: {}", + command + ))), } } @@ -161,16 +171,15 @@ fn create_tls_acceptor() -> MrcResult { let password = env::var("TLS_PASSWORD") .map_err(|_| MrcError::InvalidInput("TLS_PASSWORD not set".to_string()))?; - let mut file = std::fs::File::open(&pfx_path) - .map_err(MrcError::ConnectionError)?; + let mut file = std::fs::File::open(&pfx_path).map_err(MrcError::ConnectionError)?; let mut identity = vec![]; file.read_to_end(&mut identity) .map_err(MrcError::ConnectionError)?; let identity = Identity::from_pkcs12(&identity, &password) .map_err(|e| MrcError::TlsError(e.to_string()))?; - let native_acceptor = NativeTlsAcceptor::new(identity) - .map_err(|e| MrcError::TlsError(e.to_string()))?; + let native_acceptor = + NativeTlsAcceptor::new(identity).map_err(|e| MrcError::TlsError(e.to_string()))?; Ok(TlsAcceptor::from(native_acceptor)) } @@ -194,13 +203,13 @@ async fn main() -> MrcResult<()> { match create_tls_acceptor() { Ok(acceptor) => { let acceptor = Arc::new(acceptor); - let listener = tokio::net::TcpListener::bind(&config.bind).await + let listener = tokio::net::TcpListener::bind(&config.bind) + .await .map_err(MrcError::ConnectionError)?; info!("Server is listening on {}", config.bind); loop { - let (stream, _) = listener.accept().await - .map_err(MrcError::ConnectionError)?; + let (stream, _) = listener.accept().await.map_err(MrcError::ConnectionError)?; info!("New connection accepted."); let acceptor = Arc::clone(&acceptor); From 74f2927b8679c1b59f36984ab720f424b934c3c3 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 12 Jun 2025 18:14:34 +0300 Subject: [PATCH 2/8] mrc: refactor interactive mode into its own module --- src/cli.rs | 149 +--------------------------------------- src/interactive.rs | 168 +++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 25 ++++--- 3 files changed, 186 insertions(+), 156 deletions(-) create mode 100644 src/interactive.rs diff --git a/src/cli.rs b/src/cli.rs index 2634a6b9..8bd5e139 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,14 +1,12 @@ use clap::{Parser, Subcommand}; use mrc::SOCKET_PATH; +use mrc::interactive::InteractiveMode; use mrc::{ MrcError, Result, get_property, loadfile, playlist_clear, playlist_move, playlist_next, playlist_prev, playlist_remove, quit, seek, set_property, }; use serde_json::json; -use std::{ - io::{self, Write}, - path::PathBuf, -}; +use std::path::PathBuf; use tracing::{debug, error, info}; #[derive(Parser)] @@ -206,148 +204,7 @@ async fn main() -> Result<()> { } CommandOptions::Interactive => { - println!("Entering interactive mode. Type 'exit' to quit."); - let stdin = io::stdin(); - let mut stdout = io::stdout(); - - loop { - print!("mpv> "); - stdout.flush().map_err(MrcError::ConnectionError)?; - let mut input = String::new(); - stdin - .read_line(&mut input) - .map_err(MrcError::ConnectionError)?; - let trimmed = input.trim(); - - if trimmed.eq_ignore_ascii_case("exit") { - println!("Exiting interactive mode."); - break; - } - - // I don't like this either, but it looks cleaner than a multi-line - // print macro just cramped in here. - let commands = vec![ - ( - "play [index]", - "Play or unpause playback, optionally at the specified index", - ), - ("pause", "Pause playback"), - ("stop", "Stop playback and quit MPV"), - ("next", "Skip to the next item in the playlist"), - ("prev", "Skip to the previous item in the playlist"), - ("seek ", "Seek to the specified position"), - ("clear", "Clear the playlist"), - ("list", "List all items in the playlist"), - ("add ", "Add files to the playlist"), - ("get ", "Get the specified property"), - ( - "set ", - "Set the specified property to a value", - ), - ("help", "Show this help message"), - ("exit", "Quit interactive mode"), - ]; - - if trimmed.eq_ignore_ascii_case("help") { - println!("Valid commands:"); - for (command, description) in commands { - println!(" {} - {}", command, description); - } - continue; - } - - let parts: Vec<&str> = trimmed.split_whitespace().collect(); - match parts.as_slice() { - ["play"] => { - info!("Unpausing playback"); - set_property("pause", &json!(false), None).await?; - } - - ["play", index] => { - if let Ok(idx) = index.parse::() { - info!("Playing media at index: {}", idx); - set_property("playlist-pos", &json!(idx), None).await?; - set_property("pause", &json!(false), None).await?; - } else { - println!("Invalid index: {}", index); - } - } - - ["pause"] => { - info!("Pausing playback"); - set_property("pause", &json!(true), None).await?; - } - - ["stop"] => { - info!("Pausing playback"); - quit(None).await?; - } - - ["next"] => { - info!("Skipping to next item in the playlist"); - playlist_next(None).await?; - } - - ["prev"] => { - info!("Skipping to previous item in the playlist"); - playlist_prev(None).await?; - } - - ["seek", seconds] => { - if let Ok(sec) = seconds.parse::() { - info!("Seeking to {} seconds", sec); - seek(sec.into(), None).await?; - } else { - println!("Invalid seconds: {}", seconds); - } - } - - ["clear"] => { - info!("Clearing the playlist"); - playlist_clear(None).await?; - } - - ["list"] => { - info!("Listing playlist items"); - if let Some(data) = get_property("playlist", None).await? { - let pretty_json = serde_json::to_string_pretty(&data) - .map_err(MrcError::ParseError)?; - println!("{}", pretty_json); - } - } - - ["add", files @ ..] => { - if files.is_empty() { - println!("No files provided to add to the playlist"); - } else { - info!("Adding {} files to the playlist", files.len()); - for file in files { - loadfile(file, true, None).await?; - } - } - } - - ["get", property] => { - if let Some(data) = get_property(property, None).await? { - println!("{property}: {data}"); - } - } - - ["set", property, value] => { - let json_value = serde_json::from_str::(value) - .unwrap_or_else(|_| json!(value)); - set_property(property, &json_value, None).await?; - println!("Set {property} to {value}"); - } - - _ => { - println!("Unknown command: {}", trimmed); - println!( - "Valid commands: play , pause, stop, next, prev, seek , clear, list, add , get , set , help, exit" - ); - } - } - } + InteractiveMode::run().await?; } } diff --git a/src/interactive.rs b/src/interactive.rs new file mode 100644 index 00000000..9a2cf2b7 --- /dev/null +++ b/src/interactive.rs @@ -0,0 +1,168 @@ +use crate::{ + MrcError, Result, get_property, loadfile, playlist_clear, playlist_next, playlist_prev, quit, + seek, set_property, +}; +use serde_json::json; +use std::io::{self, Write}; +use tracing::info; + +pub struct InteractiveMode; + +impl InteractiveMode { + pub async fn run() -> Result<()> { + println!("Entering interactive mode. Type 'help' for commands or 'exit' to quit."); + let stdin = io::stdin(); + let mut stdout = io::stdout(); + + loop { + print!("mpv> "); + stdout.flush().map_err(MrcError::ConnectionError)?; + + let mut input = String::new(); + stdin + .read_line(&mut input) + .map_err(MrcError::ConnectionError)?; + let trimmed = input.trim(); + + if trimmed.eq_ignore_ascii_case("exit") { + println!("Exiting interactive mode."); + break; + } + + if trimmed.eq_ignore_ascii_case("help") { + Self::show_help(); + continue; + } + + if let Err(e) = Self::process_command(trimmed).await { + eprintln!("Error: {}", e); + } + } + + Ok(()) + } + + fn show_help() { + println!("Available commands:"); + let commands = [ + ( + "play [index]", + "Play or unpause playback, optionally at the specified index", + ), + ("pause", "Pause playback"), + ("stop", "Stop playback and quit MPV"), + ("next", "Skip to the next item in the playlist"), + ("prev", "Skip to the previous item in the playlist"), + ("seek ", "Seek to the specified position"), + ("clear", "Clear the playlist"), + ("list", "List all items in the playlist"), + ("add ", "Add files to the playlist"), + ("get ", "Get the specified property"), + ( + "set ", + "Set the specified property to a value", + ), + ("help", "Show this help message"), + ("exit", "Quit interactive mode"), + ]; + + for (command, description) in commands { + println!(" {} - {}", command, description); + } + } + + async fn process_command(input: &str) -> Result<()> { + let parts: Vec<&str> = input.split_whitespace().collect(); + + match parts.as_slice() { + ["play"] => { + info!("Unpausing playback"); + set_property("pause", &json!(false), None).await?; + } + + ["play", index] => { + if let Ok(idx) = index.parse::() { + info!("Playing media at index: {}", idx); + set_property("playlist-pos", &json!(idx), None).await?; + set_property("pause", &json!(false), None).await?; + } else { + println!("Invalid index: {}", index); + } + } + + ["pause"] => { + info!("Pausing playback"); + set_property("pause", &json!(true), None).await?; + } + + ["stop"] => { + info!("Stopping playback and quitting MPV"); + quit(None).await?; + } + + ["next"] => { + info!("Skipping to next item in the playlist"); + playlist_next(None).await?; + } + + ["prev"] => { + info!("Skipping to previous item in the playlist"); + playlist_prev(None).await?; + } + + ["seek", seconds] => { + if let Ok(sec) = seconds.parse::() { + info!("Seeking to {} seconds", sec); + seek(sec.into(), None).await?; + } else { + println!("Invalid seconds: {}", seconds); + } + } + + ["clear"] => { + info!("Clearing the playlist"); + playlist_clear(None).await?; + } + + ["list"] => { + info!("Listing playlist items"); + if let Some(data) = get_property("playlist", None).await? { + let pretty_json = + serde_json::to_string_pretty(&data).map_err(MrcError::ParseError)?; + println!("{}", pretty_json); + } + } + + ["add", files @ ..] => { + if files.is_empty() { + println!("No files provided to add to the playlist"); + } else { + info!("Adding {} files to the playlist", files.len()); + for file in files { + loadfile(file, true, None).await?; + } + } + } + + ["get", property] => { + if let Some(data) = get_property(property, None).await? { + println!("{}: {}", property, data); + } + } + + ["set", property, value] => { + let json_value = serde_json::from_str::(value) + .unwrap_or_else(|_| json!(value)); + set_property(property, &json_value, None).await?; + println!("Set {} to {}", property, value); + } + + _ => { + println!("Unknown command: {}", input); + println!("Type 'help' for a list of available commands."); + } + } + + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 64d6eed5..8dbc4ae0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,7 +38,8 @@ //! ### `SOCKET_PATH` //! Default path for the MPV IPC socket: `/tmp/mpvsocket` //! -//! ## Functions + +pub mod interactive; use serde_json::{Value, json}; use std::io; @@ -48,6 +49,7 @@ use tokio::net::UnixStream; use tracing::{debug, error}; pub const SOCKET_PATH: &str = "/tmp/mpvsocket"; +const SOCKET_TIMEOUT_SECS: u64 = 5; /// Errors that can occur when interacting with the MPV IPC interface. #[derive(Error, Debug)] @@ -105,11 +107,11 @@ async fn connect_to_socket(socket_path: &str) -> Result { debug!("Connecting to socket at {}", socket_path); tokio::time::timeout( - std::time::Duration::from_secs(5), + std::time::Duration::from_secs(SOCKET_TIMEOUT_SECS), UnixStream::connect(socket_path), ) .await - .map_err(|_| MrcError::SocketTimeout(5))? + .map_err(|_| MrcError::SocketTimeout(SOCKET_TIMEOUT_SECS))? .map_err(MrcError::ConnectionError) } @@ -124,16 +126,19 @@ async fn send_message(socket: &mut UnixStream, command: &str, args: &[Value]) -> // Write with timeout tokio::time::timeout( - std::time::Duration::from_secs(5), + std::time::Duration::from_secs(SOCKET_TIMEOUT_SECS), socket.write_all(message_str.as_bytes()), ) .await - .map_err(|_| MrcError::SocketTimeout(5))??; + .map_err(|_| MrcError::SocketTimeout(SOCKET_TIMEOUT_SECS))??; // Flush with timeout - tokio::time::timeout(std::time::Duration::from_secs(5), socket.flush()) - .await - .map_err(|_| MrcError::SocketTimeout(5))??; + tokio::time::timeout( + std::time::Duration::from_secs(SOCKET_TIMEOUT_SECS), + socket.flush(), + ) + .await + .map_err(|_| MrcError::SocketTimeout(SOCKET_TIMEOUT_SECS))??; debug!("Message sent and flushed"); Ok(()) @@ -145,11 +150,11 @@ async fn read_response(socket: &mut UnixStream) -> Result { // Read with timeout let n = tokio::time::timeout( - std::time::Duration::from_secs(5), + std::time::Duration::from_secs(SOCKET_TIMEOUT_SECS), socket.read(&mut response), ) .await - .map_err(|_| MrcError::SocketTimeout(5))??; + .map_err(|_| MrcError::SocketTimeout(SOCKET_TIMEOUT_SECS))??; if n == 0 { return Err(MrcError::ConnectionLost( From 69f69ece07ebb4da4b71641304447292f97adef7 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 12 Jun 2025 18:58:22 +0300 Subject: [PATCH 3/8] treewide: unify command processing and improve error handling --- src/cli.rs | 78 ++++------------ src/commands.rs | 217 +++++++++++++++++++++++++++++++++++++++++++++ src/interactive.rs | 54 ++++------- src/lib.rs | 1 + src/server.rs | 144 +++++++++++++++++------------- 5 files changed, 331 insertions(+), 163 deletions(-) create mode 100644 src/commands.rs diff --git a/src/cli.rs b/src/cli.rs index 8bd5e139..638c6ebd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,13 +1,10 @@ use clap::{Parser, Subcommand}; use mrc::SOCKET_PATH; +use mrc::commands::Commands; use mrc::interactive::InteractiveMode; -use mrc::{ - MrcError, Result, get_property, loadfile, playlist_clear, playlist_move, playlist_next, - playlist_prev, playlist_remove, quit, seek, set_property, -}; -use serde_json::json; +use mrc::{MrcError, Result}; use std::path::PathBuf; -use tracing::{debug, error, info}; +use tracing::{debug, error}; #[derive(Parser)] #[command(author, version, about)] @@ -109,98 +106,55 @@ async fn main() -> Result<()> { match cli.command { CommandOptions::Play { index } => { - if let Some(idx) = index { - info!("Playing media at index: {}", idx); - set_property("playlist-pos", &json!(idx), None).await?; - } - info!("Unpausing playback"); - set_property("pause", &json!(false), None).await?; + Commands::play(index).await?; } CommandOptions::Pause => { - info!("Pausing playback"); - set_property("pause", &json!(true), None).await?; + Commands::pause().await?; } CommandOptions::Stop => { - info!("Stopping playback and quitting MPV"); - quit(None).await?; + Commands::stop().await?; } CommandOptions::Next => { - info!("Skipping to next item in the playlist"); - playlist_next(None).await?; + Commands::next().await?; } CommandOptions::Prev => { - info!("Skipping to previous item in the playlist"); - playlist_prev(None).await?; + Commands::prev().await?; } CommandOptions::Seek { seconds } => { - info!("Seeking to {} seconds", seconds); - seek(seconds.into(), None).await?; + Commands::seek_to(seconds.into()).await?; } CommandOptions::Move { index1, index2 } => { - info!("Moving item from index {} to {}", index1, index2); - playlist_move(index1, index2, None).await?; + Commands::move_item(index1, index2).await?; } CommandOptions::Remove { index } => { - if let Some(idx) = index { - info!("Removing item at index {}", idx); - playlist_remove(Some(idx), None).await?; - } else { - info!("Removing current item from playlist"); - playlist_remove(None, None).await?; - } + Commands::remove_item(index).await?; } CommandOptions::Clear => { - info!("Clearing the playlist"); - playlist_clear(None).await?; + Commands::clear_playlist().await?; } CommandOptions::List => { - info!("Listing playlist items"); - if let Some(data) = get_property("playlist", None).await? { - let pretty_json = - serde_json::to_string_pretty(&data).map_err(MrcError::ParseError)?; - println!("{}", pretty_json); - } + Commands::list_playlist().await?; } CommandOptions::Add { filenames } => { - if filenames.is_empty() { - let e = "No files provided to add to the playlist"; - error!("{}", e); - return Err(MrcError::InvalidInput(e.to_string())); - } - - info!("Adding {} files to the playlist", filenames.len()); - for filename in filenames { - loadfile(&filename, true, None).await?; - } + Commands::add_files(&filenames).await?; } CommandOptions::Replace { filenames } => { - info!("Replacing current playlist with {} files", filenames.len()); - if let Some(first_file) = filenames.first() { - loadfile(first_file, false, None).await?; - for filename in &filenames[1..] { - loadfile(filename, true, None).await?; - } - } + Commands::replace_playlist(&filenames).await?; } CommandOptions::Prop { properties } => { - info!("Fetching properties: {:?}", properties); - for property in properties { - if let Some(data) = get_property(&property, None).await? { - println!("{property}: {data}"); - } - } + Commands::get_properties(&properties).await?; } CommandOptions::Interactive => { diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 00000000..38dc0e55 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,217 @@ +//! Command processing module for MRC. +//! +//! # Examples +//! +//! ```rust +//! use mrc::commands::Commands; +//! +//! # async fn example() -> mrc::Result<()> { +//! // Play media at a specific playlist index +//! Commands::play(Some(0)).await?; +//! +//! // Pause playback +//! Commands::pause().await?; +//! +//! // Add files to playlist +//! let files = vec!["movie1.mp4".to_string(), "movie2.mp4".to_string()]; +//! Commands::add_files(&files).await?; +//! # Ok(()) +//! # } +//! ``` + +use crate::{ + MrcError, Result, get_property, loadfile, playlist_clear, playlist_move, playlist_next, + playlist_prev, playlist_remove, quit, seek, set_property, +}; +use serde_json::json; +use tracing::info; + +/// Centralized command processing for MPV operations. +pub struct Commands; + +impl Commands { + /// Plays media, optionally at a specific playlist index. + /// + /// If an index is provided, seeks to that position in the playlist first. + /// Always unpauses playback regardless of whether an index is specified. + /// + /// # Arguments + /// + /// * `index` - Optional playlist index to play from + pub async fn play(index: Option) -> Result<()> { + if let Some(idx) = index { + info!("Playing media at index: {}", idx); + set_property("playlist-pos", &json!(idx), None).await?; + } + info!("Unpausing playback"); + set_property("pause", &json!(false), None).await?; + Ok(()) + } + + /// Pauses the currently playing media. + pub async fn pause() -> Result<()> { + info!("Pausing playback"); + set_property("pause", &json!(true), None).await?; + Ok(()) + } + + /// Stops playback and quits MPV. + /// + /// This is a destructive operation that will terminate the MPV process. + pub async fn stop() -> Result<()> { + info!("Stopping playback and quitting MPV"); + quit(None).await?; + Ok(()) + } + + /// Advances to the next item in the playlist. + pub async fn next() -> Result<()> { + info!("Skipping to next item in the playlist"); + playlist_next(None).await?; + Ok(()) + } + + /// Goes back to the previous item in the playlist. + pub async fn prev() -> Result<()> { + info!("Skipping to previous item in the playlist"); + playlist_prev(None).await?; + Ok(()) + } + + /// Seeks to a specific time position in the current media. + /// + /// # Arguments + /// + /// * `seconds` - The time position to seek to, in seconds + pub async fn seek_to(seconds: f64) -> Result<()> { + info!("Seeking to {} seconds", seconds); + seek(seconds, None).await?; + Ok(()) + } + + /// Moves a playlist item from one index to another. + /// + /// # Arguments + /// + /// * `from_index` - The current index of the item to move + /// * `to_index` - The target index to move the item to + pub async fn move_item(from_index: usize, to_index: usize) -> Result<()> { + info!("Moving item from index {} to {}", from_index, to_index); + playlist_move(from_index, to_index, None).await?; + Ok(()) + } + + /// Removes an item from the playlist. + /// + /// # Arguments + /// + /// * `index` - Optional index of the item to remove. If `None`, removes the current item + pub async fn remove_item(index: Option) -> Result<()> { + if let Some(idx) = index { + info!("Removing item at index {}", idx); + playlist_remove(Some(idx), None).await?; + } else { + info!("Removing current item from playlist"); + playlist_remove(None, None).await?; + } + Ok(()) + } + + /// Clears all items from the playlist. + pub async fn clear_playlist() -> Result<()> { + info!("Clearing the playlist"); + playlist_clear(None).await?; + Ok(()) + } + + /// Lists all items in the playlist. + /// + /// This outputs the playlist as formatted JSON to stdout. + pub async fn list_playlist() -> Result<()> { + info!("Listing playlist items"); + if let Some(data) = get_property("playlist", None).await? { + let pretty_json = serde_json::to_string_pretty(&data).map_err(MrcError::ParseError)?; + println!("{}", pretty_json); + } + Ok(()) + } + + /// Adds multiple files to the playlist. + /// + /// # Arguments + /// + /// * `filenames` - A slice of file paths to add to the playlist + /// + /// # Errors + /// + /// Returns [`MrcError::InvalidInput`] if the filenames slice is empty. + pub async fn add_files(filenames: &[String]) -> Result<()> { + if filenames.is_empty() { + let e = "No files provided to add to the playlist"; + return Err(MrcError::InvalidInput(e.to_string())); + } + + info!("Adding {} files to the playlist", filenames.len()); + for filename in filenames { + loadfile(filename, true, None).await?; + } + Ok(()) + } + + /// Replaces the current playlist with new files. + /// + /// The first file replaces the current playlist, and subsequent files are appended. + /// + /// # Arguments + /// + /// * `filenames` - A slice of file paths to replace the playlist with + pub async fn replace_playlist(filenames: &[String]) -> Result<()> { + info!("Replacing current playlist with {} files", filenames.len()); + if let Some(first_file) = filenames.first() { + loadfile(first_file, false, None).await?; + for filename in &filenames[1..] { + loadfile(filename, true, None).await?; + } + } + Ok(()) + } + + /// Retrieves and displays multiple MPV properties. + /// + /// # Arguments + /// + /// * `properties` - A slice of property names to retrieve + pub async fn get_properties(properties: &[String]) -> Result<()> { + info!("Fetching properties: {:?}", properties); + for property in properties { + if let Some(data) = get_property(property, None).await? { + println!("{property}: {data}"); + } + } + Ok(()) + } + + /// Retrieves and displays a single MPV property. + /// + /// # Arguments + /// + /// * `property` - The name of the property to retrieve + pub async fn get_single_property(property: &str) -> Result<()> { + if let Some(data) = get_property(property, None).await? { + println!("{property}: {data}"); + } + Ok(()) + } + + /// Sets an MPV property to a specific value. + /// + /// # Arguments + /// + /// * `property` - The name of the property to set + /// * `value` - The JSON value to set the property to + pub async fn set_single_property(property: &str, value: &serde_json::Value) -> Result<()> { + set_property(property, value, None).await?; + println!("Set {property} to {value}"); + Ok(()) + } +} diff --git a/src/interactive.rs b/src/interactive.rs index 9a2cf2b7..4308eb4a 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -1,10 +1,7 @@ -use crate::{ - MrcError, Result, get_property, loadfile, playlist_clear, playlist_next, playlist_prev, quit, - seek, set_property, -}; +use crate::commands::Commands; +use crate::{MrcError, Result}; use serde_json::json; use std::io::{self, Write}; -use tracing::info; pub struct InteractiveMode; @@ -76,85 +73,66 @@ impl InteractiveMode { match parts.as_slice() { ["play"] => { - info!("Unpausing playback"); - set_property("pause", &json!(false), None).await?; + Commands::play(None).await?; } ["play", index] => { if let Ok(idx) = index.parse::() { - info!("Playing media at index: {}", idx); - set_property("playlist-pos", &json!(idx), None).await?; - set_property("pause", &json!(false), None).await?; + Commands::play(Some(idx)).await?; } else { println!("Invalid index: {}", index); } } ["pause"] => { - info!("Pausing playback"); - set_property("pause", &json!(true), None).await?; + Commands::pause().await?; } ["stop"] => { - info!("Stopping playback and quitting MPV"); - quit(None).await?; + Commands::stop().await?; } ["next"] => { - info!("Skipping to next item in the playlist"); - playlist_next(None).await?; + Commands::next().await?; } ["prev"] => { - info!("Skipping to previous item in the playlist"); - playlist_prev(None).await?; + Commands::prev().await?; } ["seek", seconds] => { if let Ok(sec) = seconds.parse::() { - info!("Seeking to {} seconds", sec); - seek(sec.into(), None).await?; + Commands::seek_to(sec.into()).await?; } else { println!("Invalid seconds: {}", seconds); } } ["clear"] => { - info!("Clearing the playlist"); - playlist_clear(None).await?; + Commands::clear_playlist().await?; } ["list"] => { - info!("Listing playlist items"); - if let Some(data) = get_property("playlist", None).await? { - let pretty_json = - serde_json::to_string_pretty(&data).map_err(MrcError::ParseError)?; - println!("{}", pretty_json); - } + Commands::list_playlist().await?; } ["add", files @ ..] => { - if files.is_empty() { + let file_strings: Vec = files.iter().map(|s| s.to_string()).collect(); + if file_strings.is_empty() { println!("No files provided to add to the playlist"); } else { - info!("Adding {} files to the playlist", files.len()); - for file in files { - loadfile(file, true, None).await?; - } + Commands::add_files(&file_strings).await?; } } ["get", property] => { - if let Some(data) = get_property(property, None).await? { - println!("{}: {}", property, data); - } + Commands::get_single_property(property).await?; } ["set", property, value] => { let json_value = serde_json::from_str::(value) .unwrap_or_else(|_| json!(value)); - set_property(property, &json_value, None).await?; - println!("Set {} to {}", property, value); + Commands::set_single_property(property, &json_value).await?; } _ => { diff --git a/src/lib.rs b/src/lib.rs index 8dbc4ae0..95032266 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,6 +39,7 @@ //! Default path for the MPV IPC socket: `/tmp/mpvsocket` //! +pub mod commands; pub mod interactive; use serde_json::{Value, json}; diff --git a/src/server.rs b/src/server.rs index 3c0dd01a..ced4b3e3 100644 --- a/src/server.rs +++ b/src/server.rs @@ -4,15 +4,11 @@ use std::sync::Arc; use clap::Parser; use native_tls::{Identity, TlsAcceptor as NativeTlsAcceptor}; -use serde_json::json; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio_native_tls::TlsAcceptor; -use tracing::{debug, error, info}; +use tracing::{debug, error, info, warn}; -use mrc::{ - MrcError, Result as MrcResult, get_property, playlist_clear, playlist_next, playlist_prev, - quit, seek, set_property, -}; +use mrc::{MrcError, Result as MrcResult, commands::Commands}; #[derive(Parser)] #[command(author, version, about)] @@ -56,111 +52,133 @@ async fn handle_connection( let auth_token = match env::var("AUTH_TOKEN") { Ok(token) => token, Err(_) => { - error!("Authentication token is not set. Connection cannot be accepted."); - stream.write_all(b"Authentication token not set\n").await?; - - // You know what? I do not care to panic when the authentication token is - // missing in the environment. Start the goddamned server and hell, even - // accept incoming connections. Authenticated requests will be refused - // when the token is incorrect or not set, so we can simply continue here. + warn!("AUTH_TOKEN environment variable not set. Authentication disabled."); + let response = "HTTP/1.1 401 Unauthorized\r\nContent-Length: 29\r\n\r\nAuthentication token not set\n"; + stream.write_all(response.as_bytes()).await?; return Ok(()); } }; - if token != auth_token { - stream.write_all(b"Authentication failed\n").await?; + if token.is_empty() || token != auth_token { + warn!( + "Authentication failed for token: {}", + if token.is_empty() { + "" + } else { + "" + } + ); + let response = + "HTTP/1.1 401 Unauthorized\r\nContent-Length: 21\r\n\r\nAuthentication failed\n"; + stream.write_all(response.as_bytes()).await?; return Ok(()); } - info!("Client authenticated"); - stream.write_all(b"Authenticated\n").await?; + info!("Client authenticated successfully"); - let command = request.split("\r\n\r\n").last().unwrap_or(""); - info!("Received command: {}", command); + let command = request.split("\r\n\r\n").last().unwrap_or("").trim(); - let response = match process_command(command.trim()).await { - Ok(response) => response, + if command.is_empty() { + warn!("Received empty command"); + let response = + "HTTP/1.1 400 Bad Request\r\nContent-Length: 20\r\n\r\nNo command provided\n"; + stream.write_all(response.as_bytes()).await?; + return Ok(()); + } + + info!("Processing command: {}", command); + + let (status_code, response_body) = match process_command(command).await { + Ok(response) => ("200 OK", response), Err(e) => { - error!("Error processing command: {}", e); - format!("Error: {:?}", e) + error!("Error processing command '{}': {}", command, e); + ("400 Bad Request", format!("Error: {}\n", e)) } }; let http_response = format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}", - response.len(), - response + "HTTP/1.1 {}\r\nContent-Length: {}\r\nContent-Type: text/plain\r\n\r\n{}", + status_code, + response_body.len(), + response_body ); + stream.write_all(http_response.as_bytes()).await?; Ok(()) } async fn process_command(command: &str) -> MrcResult { - match command { - "pause" => { - info!("Pausing playback"); - set_property("pause", &json!(true), None).await?; + let parts: Vec<&str> = command.split_whitespace().collect(); + + match parts.as_slice() { + ["pause"] => { + Commands::pause().await?; Ok("Paused playback\n".to_string()) } - "play" => { - info!("Unpausing playback"); - set_property("pause", &json!(false), None).await?; + ["play"] => { + Commands::play(None).await?; Ok("Resumed playback\n".to_string()) } - "stop" => { - info!("Stopping playback and quitting MPV"); - quit(None).await?; + ["play", index] => { + if let Ok(idx) = index.parse::() { + Commands::play(Some(idx)).await?; + Ok(format!("Playing from index {}\n", idx)) + } else { + Err(MrcError::InvalidInput(format!("Invalid index: {}", index))) + } + } + + ["stop"] => { + Commands::stop().await?; Ok("Stopped playback\n".to_string()) } - "next" => { - info!("Skipping to next item in the playlist"); - playlist_next(None).await?; + ["next"] => { + Commands::next().await?; Ok("Skipped to next item\n".to_string()) } - "prev" => { - info!("Skipping to previous item in the playlist"); - playlist_prev(None).await?; + ["prev"] => { + Commands::prev().await?; Ok("Skipped to previous item\n".to_string()) } - "seek" => { - let parts: Vec<&str> = command.split_whitespace().collect(); - if let Some(seconds) = parts.get(1) { - if let Ok(sec) = seconds.parse::() { - info!("Seeking to {} seconds", sec); - seek(sec.into(), None).await?; - return Ok(format!("Seeking to {} seconds\n", sec)); - } + ["seek", seconds] => { + if let Ok(sec) = seconds.parse::() { + Commands::seek_to(sec).await?; + Ok(format!("Seeking to {} seconds\n", sec)) + } else { + Err(MrcError::InvalidInput(format!( + "Invalid seconds: {}", + seconds + ))) } - Err(MrcError::InvalidInput("Invalid seek command".to_string())) } - "clear" => { - info!("Clearing the playlist"); - playlist_clear(None).await?; + ["clear"] => { + Commands::clear_playlist().await?; Ok("Cleared playlist\n".to_string()) } - "list" => { - info!("Listing playlist items"); - match get_property("playlist", None).await { - Ok(Some(data)) => { + ["list"] => { + // For server response, we need to capture the output differently + // since Commands::list_playlist() prints to stdout + match mrc::get_property("playlist", None).await? { + Some(data) => { let pretty_json = serde_json::to_string_pretty(&data).map_err(MrcError::ParseError)?; - Ok(format!("Playlist: {}", pretty_json)) + Ok(format!("Playlist: {}\n", pretty_json)) } - Ok(None) => Err(MrcError::PropertyNotFound("playlist".to_string())), - Err(e) => Err(e), + None => Ok("Playlist is empty\n".to_string()), } } + _ => Err(MrcError::InvalidInput(format!( - "Unknown command: {}", - command + "Unknown command: {}. Available commands: pause, play [index], stop, next, prev, seek , clear, list", + command.trim() ))), } } From fb87fb9d35816fa66f2b7cf5770fc80bd8a81e18 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 12 Jun 2025 19:28:03 +0300 Subject: [PATCH 4/8] meta: vendor tooling config --- .clippy.toml | 4 ++++ .rustfmt.toml | 5 +++++ 2 files changed, 9 insertions(+) create mode 100644 .clippy.toml create mode 100644 .rustfmt.toml diff --git a/.clippy.toml b/.clippy.toml new file mode 100644 index 00000000..c5898979 --- /dev/null +++ b/.clippy.toml @@ -0,0 +1,4 @@ +avoid-breaking-exported-api = false +allowed-idents-below-min-chars = [ "x", "y", "z", "r", "g", "b", "c", "s" ] +absolute-paths-allowed-crates = [ "cstree" ] +allowed-wildcard-imports = [ "super", "Kind" ] diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 00000000..3663d8db --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,5 @@ +edition = "2024" # Keep in sync with Cargo.toml. +group_imports = "StdExternalCrate" +doc_comment_code_block_width = 100 +condense_wildcard_suffixes = true +imports_granularity = "Crate" From 702de2aeee9dad39dffae85be3db3e921009a254 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 12 Jun 2025 19:33:19 +0300 Subject: [PATCH 5/8] meta: add missing fields to Cargo manifest --- Cargo.lock | 2 +- Cargo.toml | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fdfe07fe..6a3c6ffb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -373,7 +373,7 @@ dependencies = [ [[package]] name = "mrc" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 709bc23f..10b0095b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,15 @@ [package] name = "mrc" -version = "0.1.0" +description = "MPV Remote Control - CLI and server for controlling MPV via IPC" +version = "0.2.0" edition = "2024" default-run = "cli" authors = ["NotAShelf "] +repository = "https://github.com/notashelf/mrc" +license = "MPL-2.*" +readme = "README.md" +keywords = ["mpv", "media", "player", "control", "ipc"] +categories = ["command-line-utilities", "multimedia"] # CLI implementation for terminal usage [[bin]] @@ -28,3 +34,95 @@ native-tls = "0.2" tokio-native-tls = "0.3" tracing = "0.1" tracing-subscriber = "0.3" + +[profile.dev] +opt-level = 1 + +[profile.release] +strip = true +opt-level = "s" +lto = "thin" +codegen-units = 1 +panic = "abort" + +[workspace.lints.clippy] +pedantic = { level = "warn", priority = -1 } +blanket_clippy_restriction_lints = "allow" +restriction = { level = "warn", priority = -1 } +alloc_instead_of_core = "allow" +allow_attributes_without_reason = "allow" +arbitrary_source_item_ordering = "allow" +arithmetic_side_effects = "allow" +as_conversions = "allow" +as_pointer_underscore = "allow" +as_underscore = "allow" +big_endian_bytes = "allow" +clone_on_ref_ptr = "allow" +dbg_macro = "allow" +disallowed_script_idents = "allow" +else_if_without_else = "allow" +error_impl_error = "allow" +exhaustive_enums = "allow" +exhaustive_structs = "allow" +expect_used = "allow" +field_scoped_visibility_modifiers = "allow" +float_arithmetic = "allow" +host_endian_bytes = "allow" +impl_trait_in_params = "allow" +implicit_return = "allow" +indexing_slicing = "allow" +inline_asm_x86_intel_syntax = "allow" +integer_division = "allow" +integer_division_remainder_used = "allow" +large_include_file = "allow" +let_underscore_must_use = "allow" +let_underscore_untyped = "allow" +little_endian_bytes = "allow" +map_err_ignore = "allow" +match_same_arms = "allow" +missing_assert_message = "allow" +missing_docs_in_private_items = "allow" +missing_errors_doc = "allow" +missing_inline_in_public_items = "allow" +missing_panics_doc = "allow" +missing_trait_methods = "allow" +mod_module_files = "allow" +multiple_inherent_impl = "allow" +mutex_atomic = "allow" +mutex_integer = "allow" +non_ascii_literal = "allow" +panic = "allow" +panic_in_result_fn = "allow" +partial_pub_fields = "allow" +print_stderr = "allow" +print_stdout = "allow" +pub_use = "allow" +pub_with_shorthand = "allow" +pub_without_shorthand = "allow" +question_mark_used = "allow" +ref_patterns = "allow" +renamed_function_params = "allow" +same_name_method = "allow" +semicolon_outside_block = "allow" +separated_literal_suffix = "allow" +shadow_reuse = "allow" +shadow_same = "allow" +shadow_unrelated = "allow" +single_call_fn = "allow" +single_char_lifetime_names = "allow" +single_match_else = "allow" +std_instead_of_alloc = "allow" +std_instead_of_core = "allow" +string_add = "allow" +string_slice = "allow" +todo = "allow" +too_many_lines = "allow" +try_err = "allow" +unimplemented = "allow" +unnecessary_safety_comment = "allow" +unnecessary_safety_doc = "allow" +unreachable = "allow" +unwrap_in_result = "allow" +unwrap_used = "allow" +use_debug = "allow" +wildcard_enum_match_arm = "allow" From e31062d0ed8d026df2d0af99043aedf50d7c7455 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 12 Jun 2025 19:36:26 +0300 Subject: [PATCH 6/8] commands: add basic tests --- src/commands.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/commands.rs b/src/commands.rs index 38dc0e55..eede5ad7 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -215,3 +215,33 @@ impl Commands { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_add_files_empty_list() { + let result = Commands::add_files(&[]).await; + assert!(result.is_err()); + if let Err(MrcError::InvalidInput(msg)) = result { + assert_eq!(msg, "No files provided to add to the playlist"); + } else { + panic!("Expected InvalidInput error"); + } + } + + #[tokio::test] + async fn test_replace_playlist_empty() { + let result = Commands::replace_playlist(&[]).await; + // Should succeed (no-op) with empty list + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_get_properties_empty() { + let result = Commands::get_properties(&[]).await; + // Should succeed (no-op) with empty list + assert!(result.is_ok()); + } +} From b7332bee26ea7b9688227b93f8e595ec19aa4051 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 3 Oct 2025 10:19:40 +0300 Subject: [PATCH 7/8] ci: run `cargo test` in Rust workflow Signed-off-by: NotAShelf Change-Id: I6a6a6964f95cb2581aec6ed60d3d0af8bfa9e994 --- .github/workflows/rust.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index f3596c06..0d27d9ac 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -14,5 +14,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Build run: cargo build --verbose + + - name: Test + run: cargo test --verbose From 36f0f60879c64663bfa47cac6dd11e443a2df80d Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 3 Oct 2025 10:20:22 +0300 Subject: [PATCH 8/8] lib: add some basic tests Signed-off-by: NotAShelf Change-Id: I6a6a696493dad898ba0622e9e683b83deffce2d9 --- src/lib.rs | 199 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 95032266..a8d59a8c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -456,3 +456,202 @@ pub async fn loadfile( ) .await } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::error::Error; + + #[test] + fn test_mrc_error_display() { + let error = MrcError::InvalidInput("test message".to_string()); + assert_eq!(error.to_string(), "invalid input: test message"); + } + + #[test] + fn test_mrc_error_from_io_error() { + let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); + let mrc_error = MrcError::from(io_error); + assert!(matches!(mrc_error, MrcError::ConnectionError(_))); + } + + #[test] + fn test_mrc_error_from_json_error() { + let json_error = serde_json::from_str::("invalid json").unwrap_err(); + let mrc_error = MrcError::from(json_error); + assert!(matches!(mrc_error, MrcError::ParseError(_))); + } + + #[test] + fn test_socket_timeout_error() { + let error = MrcError::SocketTimeout(5); + assert_eq!( + error.to_string(), + "socket operation timed out after 5 seconds" + ); + } + + #[test] + fn test_mpv_error() { + let error = MrcError::MpvError("playback failed".to_string()); + assert_eq!(error.to_string(), "MPV error: playback failed"); + } + + #[test] + fn test_property_not_found_error() { + let error = MrcError::PropertyNotFound("volume".to_string()); + assert_eq!(error.to_string(), "property 'volume' not found"); + } + + #[test] + fn test_connection_lost_error() { + let error = MrcError::ConnectionLost("socket closed".to_string()); + assert_eq!(error.to_string(), "server connection lost: socket closed"); + } + + #[test] + fn test_tls_error() { + let error = MrcError::TlsError("certificate invalid".to_string()); + assert_eq!(error.to_string(), "TLS error: certificate invalid"); + } + + #[test] + fn test_error_trait_implementation() { + let error = MrcError::InvalidInput("test".to_string()); + assert!(error.source().is_none()); + + let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "test"); + let connection_error = MrcError::ConnectionError(io_error); + assert!(connection_error.source().is_some()); + } + + #[test] + fn test_mpv_command_as_str() { + assert_eq!(MpvCommand::SetProperty.as_str(), "set_property"); + assert_eq!(MpvCommand::PlaylistNext.as_str(), "playlist-next"); + assert_eq!(MpvCommand::PlaylistPrev.as_str(), "playlist-prev"); + assert_eq!(MpvCommand::Seek.as_str(), "seek"); + assert_eq!(MpvCommand::Quit.as_str(), "quit"); + assert_eq!(MpvCommand::PlaylistMove.as_str(), "playlist-move"); + assert_eq!(MpvCommand::PlaylistRemove.as_str(), "playlist-remove"); + assert_eq!(MpvCommand::PlaylistClear.as_str(), "playlist-clear"); + assert_eq!(MpvCommand::GetProperty.as_str(), "get_property"); + assert_eq!(MpvCommand::LoadFile.as_str(), "loadfile"); + } + + #[test] + fn test_mpv_command_debug() { + let cmd = MpvCommand::SetProperty; + let debug_str = format!("{:?}", cmd); + assert_eq!(debug_str, "SetProperty"); + } + + #[test] + fn test_result_type_alias() { + fn test_function() -> Result { + Ok("test".to_string()) + } + + let result = test_function(); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "test"); + } + + #[test] + fn test_error_variants_exhaustive() { + // Test that all error variants are properly handled + let errors = vec![ + MrcError::ConnectionError(std::io::Error::new(std::io::ErrorKind::Other, "test")), + MrcError::ParseError(serde_json::from_str::("").unwrap_err()), + MrcError::SocketTimeout(10), + MrcError::MpvError("test".to_string()), + MrcError::PropertyNotFound("test".to_string()), + MrcError::InvalidUtf8(String::from_utf8(vec![0, 159, 146, 150]).unwrap_err()), + MrcError::NetworkError("test".to_string()), + MrcError::ConnectionLost("test".to_string()), + MrcError::ProtocolError("test".to_string()), + MrcError::InvalidInput("test".to_string()), + MrcError::TlsError("test".to_string()), + ]; + + for error in errors { + // Ensure all errors implement Display + let _ = error.to_string(); + // Ensure all errors implement Debug + let _ = format!("{:?}", error); + } + } + + // Mock tests for functions that would require MPV socket + #[tokio::test] + async fn test_loadfile_append_flag_true() { + // Test that loadfile creates correct append flag for true + // This would fail with socket connection, but tests the parameter handling + let result = loadfile("test.mp4", true, Some("/nonexistent/socket")).await; + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), MrcError::ConnectionError(_))); + } + + #[tokio::test] + async fn test_loadfile_append_flag_false() { + // Test that loadfile creates correct append flag for false + let result = loadfile("test.mp4", false, Some("/nonexistent/socket")).await; + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), MrcError::ConnectionError(_))); + } + + #[tokio::test] + async fn test_seek_parameter_handling() { + let result = seek(42.5, Some("/nonexistent/socket")).await; + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), MrcError::ConnectionError(_))); + } + + #[tokio::test] + async fn test_playlist_move_parameter_handling() { + let result = playlist_move(0, 1, Some("/nonexistent/socket")).await; + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), MrcError::ConnectionError(_))); + } + + #[tokio::test] + async fn test_set_property_parameter_handling() { + let result = set_property("volume", &json!(50), Some("/nonexistent/socket")).await; + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), MrcError::ConnectionError(_))); + } + + #[tokio::test] + async fn test_get_property_parameter_handling() { + let result = get_property("volume", Some("/nonexistent/socket")).await; + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), MrcError::ConnectionError(_))); + } + + #[tokio::test] + async fn test_playlist_operations_error_handling() { + // Test that all playlist operations handle connection errors properly + let socket_path = Some("/nonexistent/socket"); + + let results = vec![ + playlist_next(socket_path).await, + playlist_prev(socket_path).await, + playlist_clear(socket_path).await, + playlist_remove(Some(0), socket_path).await, + playlist_remove(None, socket_path).await, + ]; + + for result in results { + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), MrcError::ConnectionError(_))); + } + } + + #[tokio::test] + async fn test_quit_command() { + let result = quit(Some("/nonexistent/socket")).await; + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), MrcError::ConnectionError(_))); + } +}