Merge pull request #7 from NotAShelf/spring-cleanup

Late Spring Cleanup
This commit is contained in:
raf 2025-10-03 11:26:00 +03:00 committed by GitHub
commit 06b1c21d8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 900 additions and 334 deletions

4
.clippy.toml Normal file
View file

@ -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" ]

2
.envrc
View file

@ -1 +1 @@
use flake
use flake . --substituters "https://cache.nixos.org"

View file

@ -16,3 +16,6 @@ jobs:
- uses: actions/checkout@v5
- name: Build
run: cargo build --verbose
- name: Test
run: cargo test --verbose

5
.rustfmt.toml Normal file
View file

@ -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"

2
Cargo.lock generated
View file

@ -373,7 +373,7 @@ dependencies = [
[[package]]
name = "mrc"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"anyhow",
"clap",

View file

@ -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 <raf@notashelf.dev>"]
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"

View file

@ -1,13 +1,10 @@
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,
};
use serde_json::json;
use std::{io::{self, Write}, path::PathBuf};
use tracing::{debug, error, info};
use mrc::commands::Commands;
use mrc::interactive::InteractiveMode;
use mrc::{MrcError, Result};
use std::path::PathBuf;
use tracing::{debug, error};
#[derive(Parser)]
#[command(author, version, about)]
@ -109,239 +106,59 @@ 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 => {
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 <seconds>", "Seek to the specified position"),
("clear", "Clear the playlist"),
("list", "List all items in the playlist"),
("add <files>", "Add files to the playlist"),
("get <property>", "Get the specified property"),
(
"set <property> <value>",
"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::<usize>() {
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::<i32>() {
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::<serde_json::Value>(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 <index>, pause, stop, next, prev, seek <seconds>, clear, list, add <files>, get <property>, set <property> <value>, help, exit");
}
}
}
InteractiveMode::run().await?;
}
}

247
src/commands.rs Normal file
View file

@ -0,0 +1,247 @@
//! 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<usize>) -> 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<usize>) -> 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(())
}
}
#[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());
}
}

146
src/interactive.rs Normal file
View file

@ -0,0 +1,146 @@
use crate::commands::Commands;
use crate::{MrcError, Result};
use serde_json::json;
use std::io::{self, Write};
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 <seconds>", "Seek to the specified position"),
("clear", "Clear the playlist"),
("list", "List all items in the playlist"),
("add <files>", "Add files to the playlist"),
("get <property>", "Get the specified property"),
(
"set <property> <value>",
"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"] => {
Commands::play(None).await?;
}
["play", index] => {
if let Ok(idx) = index.parse::<usize>() {
Commands::play(Some(idx)).await?;
} else {
println!("Invalid index: {}", index);
}
}
["pause"] => {
Commands::pause().await?;
}
["stop"] => {
Commands::stop().await?;
}
["next"] => {
Commands::next().await?;
}
["prev"] => {
Commands::prev().await?;
}
["seek", seconds] => {
if let Ok(sec) = seconds.parse::<i32>() {
Commands::seek_to(sec.into()).await?;
} else {
println!("Invalid seconds: {}", seconds);
}
}
["clear"] => {
Commands::clear_playlist().await?;
}
["list"] => {
Commands::list_playlist().await?;
}
["add", files @ ..] => {
let file_strings: Vec<String> = files.iter().map(|s| s.to_string()).collect();
if file_strings.is_empty() {
println!("No files provided to add to the playlist");
} else {
Commands::add_files(&file_strings).await?;
}
}
["get", property] => {
Commands::get_single_property(property).await?;
}
["set", property, value] => {
let json_value = serde_json::from_str::<serde_json::Value>(value)
.unwrap_or_else(|_| json!(value));
Commands::set_single_property(property, &json_value).await?;
}
_ => {
println!("Unknown command: {}", input);
println!("Type 'help' for a list of available commands.");
}
}
Ok(())
}
}

View file

@ -38,9 +38,11 @@
//! ### `SOCKET_PATH`
//! Default path for the MPV IPC socket: `/tmp/mpvsocket`
//!
//! ## Functions
use serde_json::{json, Value};
pub mod commands;
pub mod interactive;
use serde_json::{Value, json};
use std::io;
use thiserror::Error;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@ -48,6 +50,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)]
@ -55,43 +58,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 +103,84 @@ pub enum MrcError {
/// A specialized Result type for MRC operations.
pub type Result<T> = std::result::Result<T, MrcError>;
/// Connects to the MPV IPC socket with timeout.
async fn connect_to_socket(socket_path: &str) -> Result<UnixStream> {
debug!("Connecting to socket at {}", socket_path);
tokio::time::timeout(
std::time::Duration::from_secs(SOCKET_TIMEOUT_SECS),
UnixStream::connect(socket_path),
)
.await
.map_err(|_| MrcError::SocketTimeout(SOCKET_TIMEOUT_SECS))?
.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(SOCKET_TIMEOUT_SECS),
socket.write_all(message_str.as_bytes()),
)
.await
.map_err(|_| MrcError::SocketTimeout(SOCKET_TIMEOUT_SECS))??;
// Flush with timeout
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(())
}
/// Reads and parses the response from the socket.
async fn read_response(socket: &mut UnixStream) -> Result<Value> {
let mut response = vec![0; 1024];
// Read with timeout
let n = tokio::time::timeout(
std::time::Duration::from_secs(SOCKET_TIMEOUT_SECS),
socket.read(&mut response),
)
.await
.map_err(|_| MrcError::SocketTimeout(SOCKET_TIMEOUT_SECS))??;
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::<Value>(&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 +204,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::<Value>(&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())
}
@ -436,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::<serde_json::Value>("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<String> {
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::<serde_json::Value>("").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(_)));
}
}

View file

@ -4,12 +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::{get_property, playlist_clear, playlist_next, playlist_prev, quit, seek, set_property, MrcError, Result as MrcResult};
use mrc::{MrcError, Result as MrcResult, commands::Commands};
#[derive(Parser)]
#[command(author, version, about)]
@ -27,11 +26,15 @@ async fn handle_connection(
stream: tokio::net::TcpStream,
acceptor: Arc<TlsAcceptor>,
) -> 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]);
@ -49,109 +52,134 @@ 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() {
"<empty>"
} else {
"<redacted>"
}
);
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<String> {
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::<usize>() {
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::<i32>() {
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::<f64>() {
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)) => {
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),
["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: {}\n", pretty_json))
}
None => Ok("Playlist is empty\n".to_string()),
}
}
_ => Err(MrcError::InvalidInput(format!("Unknown command: {}", command))),
_ => Err(MrcError::InvalidInput(format!(
"Unknown command: {}. Available commands: pause, play [index], stop, next, prev, seek <seconds>, clear, list",
command.trim()
))),
}
}
@ -161,16 +189,15 @@ fn create_tls_acceptor() -> MrcResult<TlsAcceptor> {
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 +221,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);