mirror of
https://github.com/NotAShelf/mpvrc.git
synced 2026-04-15 15:33:47 +00:00
Merge pull request #7 from NotAShelf/spring-cleanup
Late Spring Cleanup
This commit is contained in:
commit
06b1c21d8a
11 changed files with 900 additions and 334 deletions
4
.clippy.toml
Normal file
4
.clippy.toml
Normal 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
2
.envrc
|
|
@ -1 +1 @@
|
|||
use flake
|
||||
use flake . --substituters "https://cache.nixos.org"
|
||||
|
|
|
|||
3
.github/workflows/rust.yml
vendored
3
.github/workflows/rust.yml
vendored
|
|
@ -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
5
.rustfmt.toml
Normal 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
2
Cargo.lock
generated
|
|
@ -373,7 +373,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "mrc"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
|
|
|
|||
100
Cargo.toml
100
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 <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"
|
||||
|
|
|
|||
221
src/cli.rs
221
src/cli.rs
|
|
@ -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
247
src/commands.rs
Normal 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
146
src/interactive.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
371
src/lib.rs
371
src/lib.rs
|
|
@ -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(_)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
169
src/server.rs
169
src/server.rs
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue