mirror of
https://github.com/NotAShelf/mpvrc.git
synced 2026-04-17 00:13:48 +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
|
- uses: actions/checkout@v5
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --verbose
|
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]]
|
[[package]]
|
||||||
name = "mrc"
|
name = "mrc"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
|
|
|
||||||
100
Cargo.toml
100
Cargo.toml
|
|
@ -1,9 +1,15 @@
|
||||||
[package]
|
[package]
|
||||||
name = "mrc"
|
name = "mrc"
|
||||||
version = "0.1.0"
|
description = "MPV Remote Control - CLI and server for controlling MPV via IPC"
|
||||||
|
version = "0.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
default-run = "cli"
|
default-run = "cli"
|
||||||
authors = ["NotAShelf <raf@notashelf.dev>"]
|
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
|
# CLI implementation for terminal usage
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|
@ -28,3 +34,95 @@ native-tls = "0.2"
|
||||||
tokio-native-tls = "0.3"
|
tokio-native-tls = "0.3"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
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 clap::{Parser, Subcommand};
|
||||||
use mrc::set_property;
|
|
||||||
use mrc::SOCKET_PATH;
|
use mrc::SOCKET_PATH;
|
||||||
use mrc::{
|
use mrc::commands::Commands;
|
||||||
get_property, loadfile, playlist_clear, playlist_move, playlist_next, playlist_prev,
|
use mrc::interactive::InteractiveMode;
|
||||||
playlist_remove, quit, seek, MrcError, Result,
|
use mrc::{MrcError, Result};
|
||||||
};
|
use std::path::PathBuf;
|
||||||
use serde_json::json;
|
use tracing::{debug, error};
|
||||||
use std::{io::{self, Write}, path::PathBuf};
|
|
||||||
use tracing::{debug, error, info};
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(author, version, about)]
|
#[command(author, version, about)]
|
||||||
|
|
@ -109,239 +106,59 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
CommandOptions::Play { index } => {
|
CommandOptions::Play { index } => {
|
||||||
if let Some(idx) = index {
|
Commands::play(index).await?;
|
||||||
info!("Playing media at index: {}", idx);
|
|
||||||
set_property("playlist-pos", &json!(idx), None).await?;
|
|
||||||
}
|
|
||||||
info!("Unpausing playback");
|
|
||||||
set_property("pause", &json!(false), None).await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandOptions::Pause => {
|
CommandOptions::Pause => {
|
||||||
info!("Pausing playback");
|
Commands::pause().await?;
|
||||||
set_property("pause", &json!(true), None).await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandOptions::Stop => {
|
CommandOptions::Stop => {
|
||||||
info!("Stopping playback and quitting MPV");
|
Commands::stop().await?;
|
||||||
quit(None).await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandOptions::Next => {
|
CommandOptions::Next => {
|
||||||
info!("Skipping to next item in the playlist");
|
Commands::next().await?;
|
||||||
playlist_next(None).await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandOptions::Prev => {
|
CommandOptions::Prev => {
|
||||||
info!("Skipping to previous item in the playlist");
|
Commands::prev().await?;
|
||||||
playlist_prev(None).await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandOptions::Seek { seconds } => {
|
CommandOptions::Seek { seconds } => {
|
||||||
info!("Seeking to {} seconds", seconds);
|
Commands::seek_to(seconds.into()).await?;
|
||||||
seek(seconds.into(), None).await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandOptions::Move { index1, index2 } => {
|
CommandOptions::Move { index1, index2 } => {
|
||||||
info!("Moving item from index {} to {}", index1, index2);
|
Commands::move_item(index1, index2).await?;
|
||||||
playlist_move(index1, index2, None).await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandOptions::Remove { index } => {
|
CommandOptions::Remove { index } => {
|
||||||
if let Some(idx) = index {
|
Commands::remove_item(index).await?;
|
||||||
info!("Removing item at index {}", idx);
|
|
||||||
playlist_remove(Some(idx), None).await?;
|
|
||||||
} else {
|
|
||||||
info!("Removing current item from playlist");
|
|
||||||
playlist_remove(None, None).await?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandOptions::Clear => {
|
CommandOptions::Clear => {
|
||||||
info!("Clearing the playlist");
|
Commands::clear_playlist().await?;
|
||||||
playlist_clear(None).await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandOptions::List => {
|
CommandOptions::List => {
|
||||||
info!("Listing playlist items");
|
Commands::list_playlist().await?;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandOptions::Add { filenames } => {
|
CommandOptions::Add { filenames } => {
|
||||||
if filenames.is_empty() {
|
Commands::add_files(&filenames).await?;
|
||||||
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?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandOptions::Replace { filenames } => {
|
CommandOptions::Replace { filenames } => {
|
||||||
info!("Replacing current playlist with {} files", filenames.len());
|
Commands::replace_playlist(&filenames).await?;
|
||||||
if let Some(first_file) = filenames.first() {
|
|
||||||
loadfile(first_file, false, None).await?;
|
|
||||||
for filename in &filenames[1..] {
|
|
||||||
loadfile(filename, true, None).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandOptions::Prop { properties } => {
|
CommandOptions::Prop { properties } => {
|
||||||
info!("Fetching properties: {:?}", properties);
|
Commands::get_properties(&properties).await?;
|
||||||
for property in properties {
|
|
||||||
if let Some(data) = get_property(&property, None).await? {
|
|
||||||
println!("{property}: {data}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandOptions::Interactive => {
|
CommandOptions::Interactive => {
|
||||||
println!("Entering interactive mode. Type 'exit' to quit.");
|
InteractiveMode::run().await?;
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
351
src/lib.rs
351
src/lib.rs
|
|
@ -38,9 +38,11 @@
|
||||||
//! ### `SOCKET_PATH`
|
//! ### `SOCKET_PATH`
|
||||||
//! Default path for the MPV IPC socket: `/tmp/mpvsocket`
|
//! 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 std::io;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
@ -48,6 +50,7 @@ use tokio::net::UnixStream;
|
||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
|
|
||||||
pub const SOCKET_PATH: &str = "/tmp/mpvsocket";
|
pub const SOCKET_PATH: &str = "/tmp/mpvsocket";
|
||||||
|
const SOCKET_TIMEOUT_SECS: u64 = 5;
|
||||||
|
|
||||||
/// Errors that can occur when interacting with the MPV IPC interface.
|
/// Errors that can occur when interacting with the MPV IPC interface.
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
|
|
@ -100,6 +103,84 @@ pub enum MrcError {
|
||||||
/// A specialized Result type for MRC operations.
|
/// A specialized Result type for MRC operations.
|
||||||
pub type Result<T> = std::result::Result<T, MrcError>;
|
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.
|
/// Sends a generic IPC command to the specified socket and returns the parsed response data.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
|
|
@ -123,70 +204,9 @@ pub async fn send_ipc_command(
|
||||||
command, args
|
command, args
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add timeout for connection
|
let mut socket = connect_to_socket(socket_path).await?;
|
||||||
let stream = tokio::time::timeout(
|
send_message(&mut socket, command, args).await?;
|
||||||
std::time::Duration::from_secs(5),
|
let json_response = read_response(&mut socket).await?;
|
||||||
UnixStream::connect(socket_path),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| MrcError::SocketTimeout(5))?
|
|
||||||
.map_err(MrcError::ConnectionError)?;
|
|
||||||
|
|
||||||
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())
|
Ok(json_response.get("data").cloned())
|
||||||
}
|
}
|
||||||
|
|
@ -436,3 +456,202 @@ pub async fn loadfile(
|
||||||
)
|
)
|
||||||
.await
|
.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 clap::Parser;
|
||||||
use native_tls::{Identity, TlsAcceptor as NativeTlsAcceptor};
|
use native_tls::{Identity, TlsAcceptor as NativeTlsAcceptor};
|
||||||
use serde_json::json;
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio_native_tls::TlsAcceptor;
|
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)]
|
#[derive(Parser)]
|
||||||
#[command(author, version, about)]
|
#[command(author, version, about)]
|
||||||
|
|
@ -27,11 +26,15 @@ async fn handle_connection(
|
||||||
stream: tokio::net::TcpStream,
|
stream: tokio::net::TcpStream,
|
||||||
acceptor: Arc<TlsAcceptor>,
|
acceptor: Arc<TlsAcceptor>,
|
||||||
) -> MrcResult<()> {
|
) -> MrcResult<()> {
|
||||||
let mut stream = acceptor.accept(stream).await
|
let mut stream = acceptor
|
||||||
|
.accept(stream)
|
||||||
|
.await
|
||||||
.map_err(|e| MrcError::TlsError(e.to_string()))?;
|
.map_err(|e| MrcError::TlsError(e.to_string()))?;
|
||||||
let mut buffer = vec![0; 2048];
|
let mut buffer = vec![0; 2048];
|
||||||
|
|
||||||
let n = stream.read(&mut buffer).await
|
let n = stream
|
||||||
|
.read(&mut buffer)
|
||||||
|
.await
|
||||||
.map_err(MrcError::ConnectionError)?;
|
.map_err(MrcError::ConnectionError)?;
|
||||||
let request = String::from_utf8_lossy(&buffer[..n]);
|
let request = String::from_utf8_lossy(&buffer[..n]);
|
||||||
|
|
||||||
|
|
@ -49,109 +52,134 @@ async fn handle_connection(
|
||||||
let auth_token = match env::var("AUTH_TOKEN") {
|
let auth_token = match env::var("AUTH_TOKEN") {
|
||||||
Ok(token) => token,
|
Ok(token) => token,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
error!("Authentication token is not set. Connection cannot be accepted.");
|
warn!("AUTH_TOKEN environment variable not set. Authentication disabled.");
|
||||||
stream.write_all(b"Authentication token not set\n").await?;
|
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?;
|
||||||
// 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.
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if token != auth_token {
|
if token.is_empty() || token != auth_token {
|
||||||
stream.write_all(b"Authentication failed\n").await?;
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Client authenticated");
|
info!("Client authenticated successfully");
|
||||||
stream.write_all(b"Authenticated\n").await?;
|
|
||||||
|
|
||||||
let command = request.split("\r\n\r\n").last().unwrap_or("");
|
let command = request.split("\r\n\r\n").last().unwrap_or("").trim();
|
||||||
info!("Received command: {}", command);
|
|
||||||
|
|
||||||
let response = match process_command(command.trim()).await {
|
if command.is_empty() {
|
||||||
Ok(response) => response,
|
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) => {
|
Err(e) => {
|
||||||
error!("Error processing command: {}", e);
|
error!("Error processing command '{}': {}", command, e);
|
||||||
format!("Error: {:?}", e)
|
("400 Bad Request", format!("Error: {}\n", e))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let http_response = format!(
|
let http_response = format!(
|
||||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",
|
"HTTP/1.1 {}\r\nContent-Length: {}\r\nContent-Type: text/plain\r\n\r\n{}",
|
||||||
response.len(),
|
status_code,
|
||||||
response
|
response_body.len(),
|
||||||
|
response_body
|
||||||
);
|
);
|
||||||
|
|
||||||
stream.write_all(http_response.as_bytes()).await?;
|
stream.write_all(http_response.as_bytes()).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn process_command(command: &str) -> MrcResult<String> {
|
async fn process_command(command: &str) -> MrcResult<String> {
|
||||||
match command {
|
let parts: Vec<&str> = command.split_whitespace().collect();
|
||||||
"pause" => {
|
|
||||||
info!("Pausing playback");
|
match parts.as_slice() {
|
||||||
set_property("pause", &json!(true), None).await?;
|
["pause"] => {
|
||||||
|
Commands::pause().await?;
|
||||||
Ok("Paused playback\n".to_string())
|
Ok("Paused playback\n".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
"play" => {
|
["play"] => {
|
||||||
info!("Unpausing playback");
|
Commands::play(None).await?;
|
||||||
set_property("pause", &json!(false), None).await?;
|
|
||||||
Ok("Resumed playback\n".to_string())
|
Ok("Resumed playback\n".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
"stop" => {
|
["play", index] => {
|
||||||
info!("Stopping playback and quitting MPV");
|
if let Ok(idx) = index.parse::<usize>() {
|
||||||
quit(None).await?;
|
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())
|
Ok("Stopped playback\n".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
"next" => {
|
["next"] => {
|
||||||
info!("Skipping to next item in the playlist");
|
Commands::next().await?;
|
||||||
playlist_next(None).await?;
|
|
||||||
Ok("Skipped to next item\n".to_string())
|
Ok("Skipped to next item\n".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
"prev" => {
|
["prev"] => {
|
||||||
info!("Skipping to previous item in the playlist");
|
Commands::prev().await?;
|
||||||
playlist_prev(None).await?;
|
|
||||||
Ok("Skipped to previous item\n".to_string())
|
Ok("Skipped to previous item\n".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
"seek" => {
|
["seek", seconds] => {
|
||||||
let parts: Vec<&str> = command.split_whitespace().collect();
|
if let Ok(sec) = seconds.parse::<f64>() {
|
||||||
if let Some(seconds) = parts.get(1) {
|
Commands::seek_to(sec).await?;
|
||||||
if let Ok(sec) = seconds.parse::<i32>() {
|
Ok(format!("Seeking to {} seconds\n", sec))
|
||||||
info!("Seeking to {} seconds", sec);
|
} else {
|
||||||
seek(sec.into(), None).await?;
|
Err(MrcError::InvalidInput(format!(
|
||||||
return Ok(format!("Seeking to {} seconds\n", sec));
|
"Invalid seconds: {}",
|
||||||
}
|
seconds
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
Err(MrcError::InvalidInput("Invalid seek command".to_string()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
"clear" => {
|
["clear"] => {
|
||||||
info!("Clearing the playlist");
|
Commands::clear_playlist().await?;
|
||||||
playlist_clear(None).await?;
|
|
||||||
Ok("Cleared playlist\n".to_string())
|
Ok("Cleared playlist\n".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
"list" => {
|
["list"] => {
|
||||||
info!("Listing playlist items");
|
// For server response, we need to capture the output differently
|
||||||
match get_property("playlist", None).await {
|
// since Commands::list_playlist() prints to stdout
|
||||||
Ok(Some(data)) => {
|
match mrc::get_property("playlist", None).await? {
|
||||||
let pretty_json = serde_json::to_string_pretty(&data)
|
Some(data) => {
|
||||||
.map_err(MrcError::ParseError)?;
|
let pretty_json =
|
||||||
Ok(format!("Playlist: {}", pretty_json))
|
serde_json::to_string_pretty(&data).map_err(MrcError::ParseError)?;
|
||||||
},
|
Ok(format!("Playlist: {}\n", pretty_json))
|
||||||
Ok(None) => Err(MrcError::PropertyNotFound("playlist".to_string())),
|
}
|
||||||
Err(e) => Err(e),
|
None => Ok("Playlist is empty\n".to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => Err(MrcError::InvalidInput(format!("Unknown command: {}", command))),
|
|
||||||
|
_ => 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")
|
let password = env::var("TLS_PASSWORD")
|
||||||
.map_err(|_| MrcError::InvalidInput("TLS_PASSWORD not set".to_string()))?;
|
.map_err(|_| MrcError::InvalidInput("TLS_PASSWORD not set".to_string()))?;
|
||||||
|
|
||||||
let mut file = std::fs::File::open(&pfx_path)
|
let mut file = std::fs::File::open(&pfx_path).map_err(MrcError::ConnectionError)?;
|
||||||
.map_err(MrcError::ConnectionError)?;
|
|
||||||
let mut identity = vec![];
|
let mut identity = vec![];
|
||||||
file.read_to_end(&mut identity)
|
file.read_to_end(&mut identity)
|
||||||
.map_err(MrcError::ConnectionError)?;
|
.map_err(MrcError::ConnectionError)?;
|
||||||
|
|
||||||
let identity = Identity::from_pkcs12(&identity, &password)
|
let identity = Identity::from_pkcs12(&identity, &password)
|
||||||
.map_err(|e| MrcError::TlsError(e.to_string()))?;
|
.map_err(|e| MrcError::TlsError(e.to_string()))?;
|
||||||
let native_acceptor = NativeTlsAcceptor::new(identity)
|
let native_acceptor =
|
||||||
.map_err(|e| MrcError::TlsError(e.to_string()))?;
|
NativeTlsAcceptor::new(identity).map_err(|e| MrcError::TlsError(e.to_string()))?;
|
||||||
Ok(TlsAcceptor::from(native_acceptor))
|
Ok(TlsAcceptor::from(native_acceptor))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,13 +221,13 @@ async fn main() -> MrcResult<()> {
|
||||||
match create_tls_acceptor() {
|
match create_tls_acceptor() {
|
||||||
Ok(acceptor) => {
|
Ok(acceptor) => {
|
||||||
let acceptor = Arc::new(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)?;
|
.map_err(MrcError::ConnectionError)?;
|
||||||
info!("Server is listening on {}", config.bind);
|
info!("Server is listening on {}", config.bind);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let (stream, _) = listener.accept().await
|
let (stream, _) = listener.accept().await.map_err(MrcError::ConnectionError)?;
|
||||||
.map_err(MrcError::ConnectionError)?;
|
|
||||||
info!("New connection accepted.");
|
info!("New connection accepted.");
|
||||||
|
|
||||||
let acceptor = Arc::clone(&acceptor);
|
let acceptor = Arc::clone(&acceptor);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue