Compare commits

...

4 Commits

Author SHA1 Message Date
Oystein Tveit bfc27531ef WIP: add more tests
Build and test / check (push) Failing after 49s Details
Build and test / build (push) Successful in 2m3s Details
Build and test / test (push) Failing after 1m42s Details
2024-04-30 16:58:21 +02:00
Oystein Tveit 559f09dee2 WIP: workflow: generate test reports 2024-04-30 16:58:21 +02:00
Oystein Tveit 2056afe002 clean: add docstrings, move a few things around 2024-04-30 16:58:21 +02:00
Oystein Tveit 60802e1158 api: split into several files 2024-04-30 16:58:18 +02:00
11 changed files with 718 additions and 382 deletions

View File

@ -22,7 +22,7 @@ jobs:
uses: Swatinem/rust-cache@v2
- name: Build
run: cargo build --all-features --verbose
run: cargo build --all-features --verbose --release
check:
runs-on: ubuntu-latest-personal
@ -49,16 +49,62 @@ jobs:
runs-on: ubuntu-latest-personal
steps:
- uses: actions/checkout@v3
- uses: cargo-bins/cargo-binstall@main
- name: Install latest nightly toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: nightly
override: true
components: rustfmt, clippy
components: rustfmt, clippy, llvm-tools-preview
- name: Cache dependencies
uses: Swatinem/rust-cache@v2
- name: Create necessary directories
run: mkdir -p target/test-report
- name: Run tests
run: cargo test --all-features --verbose
run: cargo test --all-features --release -Z unstable-options --report-time --format json | tee target/test-report/test-report.json
env:
RUSTFLAGS: "-Cinstrument-coverage"
LLVM_PROFILE_FILE: "target/release/coverage/%p-%m.profraw"
- name: Install markdown-test-report
run: cargo binstall -y markdown-test-report
- name: Generate test report
run: markdown-test-report target/test-report/test-report.json target/test-report/test-report.md
- name: Upload test report
uses: actions/upload-artifact@v4
with:
name: test-report
path: target/test-report/test-report.md
- name: Install grcov
run: cargo binstall -y grcov
- name: Generate coverage report
run: |
grcov \
--source-dir . \
--binary-path ./target/release/deps/ \
--excl-start 'mod test* \{' \
--ignore 'tests/*' \
--ignore "*test.rs" \
--ignore "*tests.rs" \
--ignore "*github.com*" \
--ignore "*libcore*" \
--ignore "*rustc*" \
--ignore "*liballoc*" \
--ignore "*cargo*" \
-t html \
-o ./target/coverage/html \
target/coverage/prof
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage
path: target/coverage/html

View File

@ -22,7 +22,8 @@ tokio-stream = { version = "0.1.15", features = ["sync"] }
[dev-dependencies]
env_logger = "0.10.0"
test-log = "0.2.15"
tokio = { version = "1.37.0", features = ["rt-multi-thread", "time"] }
tokio = { version = "1.37.0", features = ["rt-multi-thread", "time", "process"] }
uuid = { version = "1.8.0", features = ["v4"] }
[lib]
doctest = false

44
run.sh Executable file
View File

@ -0,0 +1,44 @@
#!/usr/bin/env bash
rm -rf target
mkdir -p target/test-report
export RUSTFLAGS="-Cinstrument-coverage"
export LLVM_PROFILE_FILE="target/coverage/prof/%p-%m.profraw"
rustup override set nightly
echo "Running tests..."
cargo test --all-features --release --no-fail-fast -- -Z unstable-options --report-time --format json | tee target/test-report/test-report.json
echo "Generating test report..."
markdown-test-report target/test-report/test-report.json --output target/test-report/test-report.md
echo "Generating test report HTML..."
pandoc target/test-report/test-report.md -o target/test-report/test-report.html
# rustup override set stable
echo "Removing unused profraw files..."
for file in target/coverage/prof/*.profraw; do
~/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/bin/llvm-profdata show "$file" 1>/dev/null 2>/dev/null || rm -f "$file"
done
echo "Generating coverage report..."
grcov \
--source-dir . \
--binary-path ./target/release/deps/ \
--excl-start 'mod test* \{' \
--ignore 'tests/*' \
--ignore "*test.rs" \
--ignore "*tests.rs" \
--ignore "*github.com*" \
--ignore "*libcore*" \
--ignore "*rustc*" \
--ignore "*liballoc*" \
--ignore "*cargo*" \
-t html \
-o ./target/coverage/html \
target/coverage/prof
rustup override set nightly

View File

@ -1,51 +1,30 @@
//! The core API for interacting with [`Mpv`].
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
collections::HashMap,
fmt::{self, Display},
};
use std::{collections::HashMap, fmt};
use tokio::{
net::UnixStream,
sync::{broadcast, mpsc, oneshot},
};
use crate::ipc::{MpvIpc, MpvIpcCommand, MpvIpcEvent, MpvIpcResponse};
use crate::message_parser::TypeHandler;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Event {
Shutdown,
StartFile,
EndFile,
FileLoaded,
TracksChanged,
TrackSwitched,
Idle,
Pause,
Unpause,
Tick,
VideoReconfig,
AudioReconfig,
MetadataUpdate,
Seek,
PlaybackRestart,
PropertyChange { id: usize, property: Property },
ChapterChange,
ClientMessage { args: Vec<String> },
Unimplemented,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Property {
Path(Option<String>),
Pause(bool),
PlaybackTime(Option<f64>),
Duration(Option<f64>),
Metadata(Option<HashMap<String, MpvDataType>>),
Unknown { name: String, data: MpvDataType },
}
use crate::{
ipc::{MpvIpc, MpvIpcCommand, MpvIpcEvent, MpvIpcResponse},
message_parser::TypeHandler,
Error, ErrorCode, Event,
};
/// All possible commands that can be sent to mpv.
///
/// Not all commands are guaranteed to be implemented.
/// If something is missing, please open an issue.
///
/// You can also use the `run_command_raw` function to run commands
/// that are not implemented here.
///
/// See <https://mpv.io/manual/master/#list-of-input-commands> for
/// the upstream list of commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MpvCommand {
LoadFile {
@ -83,11 +62,13 @@ pub enum MpvCommand {
Unobserve(isize),
}
/// Helper trait to keep track of the string literals that mpv expects.
pub(crate) trait IntoRawCommandPart {
fn into_raw_command_part(self) -> String;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
/// Generic data type representing all possible data types that mpv can return.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum MpvDataType {
Array(Vec<MpvDataType>),
Bool(bool),
@ -99,6 +80,20 @@ pub enum MpvDataType {
Usize(usize),
}
/// A mpv playlist.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Playlist(pub Vec<PlaylistEntry>);
/// A single entry in the mpv playlist.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlaylistEntry {
pub id: usize,
pub filename: String,
pub title: String,
pub current: bool,
}
/// Options for [`MpvCommand::LoadFile`] and [`MpvCommand::LoadList`].
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum PlaylistAddOptions {
Replace,
@ -114,12 +109,7 @@ impl IntoRawCommandPart for PlaylistAddOptions {
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum PlaylistAddTypeOptions {
File,
Playlist,
}
/// Options for [`MpvCommand::Seek`].
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum SeekOptions {
Relative,
@ -139,35 +129,7 @@ impl IntoRawCommandPart for SeekOptions {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ErrorCode {
MpvError(String),
JsonParseError(String),
ConnectError(String),
JsonContainsUnexptectedType,
UnexpectedResult,
UnexpectedValue,
MissingValue,
UnsupportedType,
ValueDoesNotContainBool,
ValueDoesNotContainF64,
ValueDoesNotContainHashMap,
ValueDoesNotContainPlaylist,
ValueDoesNotContainString,
ValueDoesNotContainUsize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlaylistEntry {
pub id: usize,
pub filename: String,
pub title: String,
pub current: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Playlist(pub Vec<PlaylistEntry>);
/// A trait for specifying how to extract and parse a value returned through [`Mpv::get_property`].
pub trait GetPropertyTypeHandler: Sized {
// TODO: fix this
#[allow(async_fn_in_trait)]
@ -186,6 +148,7 @@ where
}
}
/// A trait for specifying how to serialize and set a value through [`Mpv::set_property`].
pub trait SetPropertyTypeHandler<T> {
// TODO: fix this
#[allow(async_fn_in_trait)]
@ -220,52 +183,14 @@ where
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Error(pub ErrorCode);
impl Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
Display::fmt(&self.0, f)
}
}
impl std::error::Error for Error {}
impl Display for ErrorCode {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
ErrorCode::ConnectError(ref msg) => f.write_str(&format!("ConnectError: {}", msg)),
ErrorCode::JsonParseError(ref msg) => f.write_str(&format!("JsonParseError: {}", msg)),
ErrorCode::MpvError(ref msg) => f.write_str(&format!("MpvError: {}", msg)),
ErrorCode::JsonContainsUnexptectedType => {
f.write_str("Mpv sent a value with an unexpected type")
}
ErrorCode::UnexpectedResult => f.write_str("Unexpected result received"),
ErrorCode::UnexpectedValue => f.write_str("Unexpected value received"),
ErrorCode::MissingValue => f.write_str("Missing value"),
ErrorCode::UnsupportedType => f.write_str("Unsupported type received"),
ErrorCode::ValueDoesNotContainBool => {
f.write_str("The received value is not of type \'std::bool\'")
}
ErrorCode::ValueDoesNotContainF64 => {
f.write_str("The received value is not of type \'std::f64\'")
}
ErrorCode::ValueDoesNotContainHashMap => {
f.write_str("The received value is not of type \'std::collections::HashMap\'")
}
ErrorCode::ValueDoesNotContainPlaylist => {
f.write_str("The received value is not of type \'mpvipc::Playlist\'")
}
ErrorCode::ValueDoesNotContainString => {
f.write_str("The received value is not of type \'std::string::String\'")
}
ErrorCode::ValueDoesNotContainUsize => {
f.write_str("The received value is not of type \'std::usize\'")
}
}
}
}
/// The main struct for interacting with mpv.
///
/// This struct provides the core API for interacting with mpv.
/// These functions are the building blocks for the higher-level API provided by the `MpvExt` trait.
/// They can also be used directly to interact with mpv in a more flexible way, mostly returning JSON values.
///
/// The `Mpv` struct can be cloned freely, and shared anywhere.
/// It only contains a message passing channel to the tokio task that handles the IPC communication with mpv.
#[derive(Clone)]
pub struct Mpv {
command_sender: mpsc::Sender<(MpvIpcCommand, oneshot::Sender<MpvIpcResponse>)>,
@ -323,7 +248,7 @@ impl Mpv {
pub async fn get_event_stream(&self) -> impl futures::Stream<Item = Result<Event, Error>> {
tokio_stream::wrappers::BroadcastStream::new(self.broadcast_channel.subscribe()).map(
|event| match event {
Ok(event) => Mpv::map_event(event),
Ok(event) => crate::event_parser::parse_event(event),
Err(_) => Err(Error(ErrorCode::ConnectError(
"Failed to receive event".to_string(),
))),
@ -331,179 +256,46 @@ impl Mpv {
)
}
fn map_event(raw_event: MpvIpcEvent) -> Result<Event, Error> {
let MpvIpcEvent(event) = raw_event;
event
.as_object()
.ok_or(Error(ErrorCode::JsonContainsUnexptectedType))
.and_then(|event| {
let event_name = event
.get("event")
.ok_or(Error(ErrorCode::MissingValue))?
.as_str()
.ok_or(Error(ErrorCode::ValueDoesNotContainString))?;
match event_name {
"shutdown" => Ok(Event::Shutdown),
"start-file" => Ok(Event::StartFile),
"end-file" => Ok(Event::EndFile),
"file-loaded" => Ok(Event::FileLoaded),
"tracks-changed" => Ok(Event::TracksChanged),
"track-switched" => Ok(Event::TrackSwitched),
"idle" => Ok(Event::Idle),
"pause" => Ok(Event::Pause),
"unpause" => Ok(Event::Unpause),
"tick" => Ok(Event::Tick),
"video-reconfig" => Ok(Event::VideoReconfig),
"audio-reconfig" => Ok(Event::AudioReconfig),
"metadata-update" => Ok(Event::MetadataUpdate),
"seek" => Ok(Event::Seek),
"playback-restart" => Ok(Event::PlaybackRestart),
"property-change" => {
let id = event
.get("id")
.ok_or(Error(ErrorCode::MissingValue))?
.as_u64()
.ok_or(Error(ErrorCode::ValueDoesNotContainUsize))?
as usize;
let property_name = event
.get("name")
.ok_or(Error(ErrorCode::MissingValue))?
.as_str()
.ok_or(Error(ErrorCode::ValueDoesNotContainString))?;
match property_name {
"path" => {
let path = event
.get("data")
.ok_or(Error(ErrorCode::MissingValue))?
.as_str()
.map(|s| s.to_string());
Ok(Event::PropertyChange {
id,
property: Property::Path(path),
})
}
"pause" => {
let pause = event
.get("data")
.ok_or(Error(ErrorCode::MissingValue))?
.as_bool()
.ok_or(Error(ErrorCode::ValueDoesNotContainBool))?;
Ok(Event::PropertyChange {
id,
property: Property::Pause(pause),
})
}
// TODO: missing cases
_ => {
let data = event
.get("data")
.ok_or(Error(ErrorCode::MissingValue))?
.clone();
Ok(Event::PropertyChange {
id,
property: Property::Unknown {
name: property_name.to_string(),
// TODO: fix
data: MpvDataType::Double(data.as_f64().unwrap_or(0.0)),
},
})
}
}
}
"chapter-change" => Ok(Event::ChapterChange),
"client-message" => {
let args = event
.get("args")
.ok_or(Error(ErrorCode::MissingValue))?
.as_array()
.ok_or(Error(ErrorCode::ValueDoesNotContainString))?
.iter()
.map(|arg| {
arg.as_str()
.ok_or(Error(ErrorCode::ValueDoesNotContainString))
.map(|s| s.to_string())
})
.collect::<Result<Vec<String>, Error>>()?;
Ok(Event::ClientMessage { args })
}
_ => Ok(Event::Unimplemented),
}
})
}
/// # Description
///
/// Retrieves the property value from mpv.
///
/// ## Supported types
/// - String
/// - bool
/// - HashMap<String, String> (e.g. for the 'metadata' property)
/// - Vec<PlaylistEntry> (for the 'playlist' property)
/// - usize
/// - f64
///
/// ## Input arguments
///
/// - **property** defines the mpv property that should be retrieved
///
/// # Example
/// ```
/// use mpvipc::{Mpv, Error};
/// async fn main() -> Result<(), Error> {
/// let mpv = Mpv::connect("/tmp/mpvsocket")?;
/// let paused: bool = mpv.get_property("pause").await?;
/// let title: String = mpv.get_property("media-title").await?;
/// Ok(())
/// }
/// ```
pub async fn get_property<T: GetPropertyTypeHandler>(
/// Run a custom command.
/// This should only be used if the desired command is not implemented
/// with [MpvCommand].
pub async fn run_command_raw(
&self,
property: &str,
) -> Result<T, Error> {
T::get_property_generic(self, property).await
}
/// # Description
///
/// Retrieves the property value from mpv.
/// The result is always of type String, regardless of the type of the value of the mpv property
///
/// ## Input arguments
///
/// - **property** defines the mpv property that should be retrieved
///
/// # Example
///
/// ```
/// use mpvipc::{Mpv, Error};
/// fn main() -> Result<(), Error> {
/// let mpv = Mpv::connect("/tmp/mpvsocket")?;
/// let title = mpv.get_property_string("media-title")?;
/// Ok(())
/// }
/// ```
pub async fn get_property_value(&self, property: &str) -> Result<Value, Error> {
command: &str,
args: &[&str],
) -> Result<Option<Value>, Error> {
let command = Vec::from(
[command]
.iter()
.chain(args.iter())
.map(|s| s.to_string())
.collect::<Vec<String>>()
.as_slice(),
);
let (res_tx, res_rx) = oneshot::channel();
self.command_sender
.send((MpvIpcCommand::GetProperty(property.to_owned()), res_tx))
.send((MpvIpcCommand::Command(command), res_tx))
.await
.map_err(|_| {
Error(ErrorCode::ConnectError(
"Failed to send command".to_string(),
))
})?;
match res_rx.await {
Ok(MpvIpcResponse(response)) => {
response.and_then(|value| value.ok_or(Error(ErrorCode::MissingValue)))
}
Ok(MpvIpcResponse(response)) => response,
Err(err) => Err(Error(ErrorCode::ConnectError(err.to_string()))),
}
}
async fn run_command_raw_ignore_value(
&self,
command: &str,
args: &[&str],
) -> Result<(), Error> {
self.run_command_raw(command, args).await.map(|_| ())
}
/// # Description
///
/// Runs mpv commands. The arguments are passed as a String-Vector reference:
@ -511,7 +303,7 @@ impl Mpv {
/// ## Input arguments
///
/// - **command** defines the mpv command that should be executed
/// - **args** a slice of &str's which define the arguments
/// - **args** a slice of `&str`'s which define the arguments
///
/// # Example
/// ```
@ -630,60 +422,90 @@ impl Mpv {
result
}
/// Run a custom command.
/// This should only be used if the desired command is not implemented
/// with [MpvCommand].
pub async fn run_command_raw(
/// # Description
///
/// Retrieves the property value from mpv.
///
/// ## Supported types
/// - `String`
/// - `bool`
/// - `HashMap<String, String>` (e.g. for the 'metadata' property)
/// - `Vec<PlaylistEntry>` (for the 'playlist' property)
/// - `usize`
/// - `f64`
///
/// ## Input arguments
///
/// - **property** defines the mpv property that should be retrieved
///
/// # Example
/// ```
/// use mpvipc::{Mpv, Error};
/// async fn main() -> Result<(), Error> {
/// let mpv = Mpv::connect("/tmp/mpvsocket")?;
/// let paused: bool = mpv.get_property("pause").await?;
/// let title: String = mpv.get_property("media-title").await?;
/// Ok(())
/// }
/// ```
pub async fn get_property<T: GetPropertyTypeHandler>(
&self,
command: &str,
args: &[&str],
) -> Result<Option<Value>, Error> {
let command = Vec::from(
[command]
.iter()
.chain(args.iter())
.map(|s| s.to_string())
.collect::<Vec<String>>()
.as_slice(),
);
property: &str,
) -> Result<T, Error> {
T::get_property_generic(self, property).await
}
/// # Description
///
/// Retrieves the property value from mpv.
/// The result is always of type String, regardless of the type of the value of the mpv property
///
/// ## Input arguments
///
/// - **property** defines the mpv property that should be retrieved
///
/// # Example
///
/// ```
/// use mpvipc::{Mpv, Error};
/// fn main() -> Result<(), Error> {
/// let mpv = Mpv::connect("/tmp/mpvsocket")?;
/// let title = mpv.get_property_string("media-title")?;
/// Ok(())
/// }
/// ```
pub async fn get_property_value(&self, property: &str) -> Result<Value, Error> {
let (res_tx, res_rx) = oneshot::channel();
self.command_sender
.send((MpvIpcCommand::Command(command), res_tx))
.send((MpvIpcCommand::GetProperty(property.to_owned()), res_tx))
.await
.map_err(|_| {
Error(ErrorCode::ConnectError(
"Failed to send command".to_string(),
))
})?;
match res_rx.await {
Ok(MpvIpcResponse(response)) => response,
Ok(MpvIpcResponse(response)) => {
response.and_then(|value| value.ok_or(Error(ErrorCode::MissingValue)))
}
Err(err) => Err(Error(ErrorCode::ConnectError(err.to_string()))),
}
}
async fn run_command_raw_ignore_value(
&self,
command: &str,
args: &[&str],
) -> Result<(), Error> {
self.run_command_raw(command, args).await.map(|_| ())
}
/// # Description
///
/// Sets the mpv property _<property>_ to _<value>_.
/// Sets the mpv property _`<property>`_ to _`<value>`_.
///
/// ## Supported types
/// - String
/// - bool
/// - f64
/// - usize
/// - `String`
/// - `bool`
/// - `f64`
/// - `usize`
///
/// ## Input arguments
///
/// - **property** defines the mpv property that should be retrieved
/// - **value** defines the value of the given mpv property _<property>_
/// - **value** defines the value of the given mpv property _`<property>`_
///
/// # Example
/// ```

72
src/error.rs Normal file
View File

@ -0,0 +1,72 @@
//! Library specific error messages.
use core::fmt;
use std::fmt::Display;
use serde::{Deserialize, Serialize};
/// All possible errors that can occur when interacting with mpv.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ErrorCode {
MpvError(String),
JsonParseError(String),
ConnectError(String),
JsonContainsUnexptectedType,
UnexpectedResult,
UnexpectedValue,
MissingValue,
UnsupportedType,
ValueDoesNotContainBool,
ValueDoesNotContainF64,
ValueDoesNotContainHashMap,
ValueDoesNotContainPlaylist,
ValueDoesNotContainString,
ValueDoesNotContainUsize,
}
/// Any error that can occur when interacting with mpv.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Error(pub ErrorCode);
impl Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
Display::fmt(&self.0, f)
}
}
impl std::error::Error for Error {}
impl Display for ErrorCode {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
ErrorCode::ConnectError(ref msg) => f.write_str(&format!("ConnectError: {}", msg)),
ErrorCode::JsonParseError(ref msg) => f.write_str(&format!("JsonParseError: {}", msg)),
ErrorCode::MpvError(ref msg) => f.write_str(&format!("MpvError: {}", msg)),
ErrorCode::JsonContainsUnexptectedType => {
f.write_str("Mpv sent a value with an unexpected type")
}
ErrorCode::UnexpectedResult => f.write_str("Unexpected result received"),
ErrorCode::UnexpectedValue => f.write_str("Unexpected value received"),
ErrorCode::MissingValue => f.write_str("Missing value"),
ErrorCode::UnsupportedType => f.write_str("Unsupported type received"),
ErrorCode::ValueDoesNotContainBool => {
f.write_str("The received value is not of type \'std::bool\'")
}
ErrorCode::ValueDoesNotContainF64 => {
f.write_str("The received value is not of type \'std::f64\'")
}
ErrorCode::ValueDoesNotContainHashMap => {
f.write_str("The received value is not of type \'std::collections::HashMap\'")
}
ErrorCode::ValueDoesNotContainPlaylist => {
f.write_str("The received value is not of type \'mpvipc::Playlist\'")
}
ErrorCode::ValueDoesNotContainString => {
f.write_str("The received value is not of type \'std::string::String\'")
}
ErrorCode::ValueDoesNotContainUsize => {
f.write_str("The received value is not of type \'std::usize\'")
}
}
}
}

160
src/event_parser.rs Normal file
View File

@ -0,0 +1,160 @@
//! JSON parsing logic for events from [`MpvIpc`](crate::ipc::MpvIpc).
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::{ipc::MpvIpcEvent, Error, ErrorCode, MpvDataType};
/// All possible properties that can be observed through the event system.
///
/// Not all properties are guaranteed to be implemented.
/// If something is missing, please open an issue.
///
/// Otherwise, the property will be returned as a `Property::Unknown` variant.
///
/// See <https://mpv.io/manual/master/#properties> for
/// the upstream list of properties.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Property {
Path(Option<String>),
Pause(bool),
PlaybackTime(Option<f64>),
Duration(Option<f64>),
Metadata(Option<HashMap<String, MpvDataType>>),
Unknown { name: String, data: MpvDataType },
}
/// All possible events that can be sent by mpv.
///
/// Not all event types are guaranteed to be implemented.
/// If something is missing, please open an issue.
///
/// Otherwise, the event will be returned as an `Event::Unimplemented` variant.
///
/// See <https://mpv.io/manual/master/#list-of-events> for
/// the upstream list of events.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Event {
Shutdown,
StartFile,
EndFile,
FileLoaded,
TracksChanged,
TrackSwitched,
Idle,
Pause,
Unpause,
Tick,
VideoReconfig,
AudioReconfig,
MetadataUpdate,
Seek,
PlaybackRestart,
PropertyChange { id: usize, property: Property },
ChapterChange,
ClientMessage { args: Vec<String> },
Unimplemented,
}
/// Parse a highlevel [`Event`] objects from json.
pub(crate) fn parse_event(raw_event: MpvIpcEvent) -> Result<Event, Error> {
let MpvIpcEvent(event) = raw_event;
event
.as_object()
.ok_or(Error(ErrorCode::JsonContainsUnexptectedType))
.and_then(|event| {
let event_name = event
.get("event")
.ok_or(Error(ErrorCode::MissingValue))?
.as_str()
.ok_or(Error(ErrorCode::ValueDoesNotContainString))?;
match event_name {
"shutdown" => Ok(Event::Shutdown),
"start-file" => Ok(Event::StartFile),
"end-file" => Ok(Event::EndFile),
"file-loaded" => Ok(Event::FileLoaded),
"tracks-changed" => Ok(Event::TracksChanged),
"track-switched" => Ok(Event::TrackSwitched),
"idle" => Ok(Event::Idle),
"pause" => Ok(Event::Pause),
"unpause" => Ok(Event::Unpause),
"tick" => Ok(Event::Tick),
"video-reconfig" => Ok(Event::VideoReconfig),
"audio-reconfig" => Ok(Event::AudioReconfig),
"metadata-update" => Ok(Event::MetadataUpdate),
"seek" => Ok(Event::Seek),
"playback-restart" => Ok(Event::PlaybackRestart),
"property-change" => parse_event_property(event)
.and_then(|(id, property)| Ok(Event::PropertyChange { id, property })),
"chapter-change" => Ok(Event::ChapterChange),
"client-message" => {
let args = event
.get("args")
.ok_or(Error(ErrorCode::MissingValue))?
.as_array()
.ok_or(Error(ErrorCode::ValueDoesNotContainString))?
.iter()
.map(|arg| {
arg.as_str()
.ok_or(Error(ErrorCode::ValueDoesNotContainString))
.map(|s| s.to_string())
})
.collect::<Result<Vec<String>, Error>>()?;
Ok(Event::ClientMessage { args })
}
_ => Ok(Event::Unimplemented),
}
})
}
/// Parse a highlevel [`Property`] object from json, used for [`Event::PropertyChange`].
fn parse_event_property(event: &Map<String, Value>) -> Result<(usize, Property), Error> {
let id = event
.get("id")
.ok_or(Error(ErrorCode::MissingValue))?
.as_u64()
.ok_or(Error(ErrorCode::ValueDoesNotContainUsize))? as usize;
let property_name = event
.get("name")
.ok_or(Error(ErrorCode::MissingValue))?
.as_str()
.ok_or(Error(ErrorCode::ValueDoesNotContainString))?;
match property_name {
"path" => {
let path = event
.get("data")
.ok_or(Error(ErrorCode::MissingValue))?
.as_str()
.map(|s| s.to_string());
Ok((id, Property::Path(path)))
}
"pause" => {
let pause = event
.get("data")
.ok_or(Error(ErrorCode::MissingValue))?
.as_bool()
.ok_or(Error(ErrorCode::ValueDoesNotContainBool))?;
Ok((id, Property::Pause(pause)))
}
// TODO: missing cases
_ => {
let data = event
.get("data")
.ok_or(Error(ErrorCode::MissingValue))?
.clone();
Ok((
id,
Property::Unknown {
name: property_name.to_string(),
// TODO: fix
data: MpvDataType::Double(data.as_f64().unwrap_or(0.0)),
},
))
}
}
}

View File

@ -1,10 +1,13 @@
//! High-level API extension for [`Mpv`].
use crate::{
Error, IntoRawCommandPart, Mpv, MpvCommand, MpvDataType, Playlist, PlaylistAddOptions,
PlaylistAddTypeOptions, PlaylistEntry, SeekOptions,
PlaylistEntry, SeekOptions,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Generic high-level command for changing a number property.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum NumberChangeOptions {
Absolute,
@ -22,6 +25,7 @@ impl IntoRawCommandPart for NumberChangeOptions {
}
}
/// Generic high-level switch for toggling boolean properties.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum Switch {
On,
@ -29,6 +33,14 @@ pub enum Switch {
Toggle,
}
/// Options for [`MpvExt::playlist_add`].
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum PlaylistAddTypeOptions {
File,
Playlist,
}
/// A set of typesafe high-level functions to interact with [`Mpv`].
// TODO: fix this
#[allow(async_fn_in_trait)]
pub trait MpvExt {

View File

@ -1,19 +1,28 @@
use super::*;
//! IPC handling thread/task. Handles communication between [`Mpv`](crate::Mpv) instances and mpv's unix socket
use futures::{SinkExt, StreamExt};
use serde_json::{json, Value};
use std::mem;
use tokio::net::UnixStream;
use tokio::sync::mpsc;
use tokio::sync::{broadcast, oneshot, Mutex};
use tokio::{
net::UnixStream,
sync::{broadcast, mpsc, oneshot, Mutex},
};
use tokio_util::codec::{Framed, LinesCodec, LinesCodecError};
use crate::{Error, ErrorCode};
/// Container for all state that regards communication with the mpv IPC socket
/// and message passing with [`Mpv`](crate::Mpv) controllers.
pub(crate) struct MpvIpc {
socket: Framed<UnixStream, LinesCodec>,
command_channel: mpsc::Receiver<(MpvIpcCommand, oneshot::Sender<MpvIpcResponse>)>,
// I had trouble with reading and writing to the socket when it was wrapped
// in a MutexGuard, so I'm using a separate Mutex to lock the socket when needed.
socket_lock: Mutex<()>,
command_channel: mpsc::Receiver<(MpvIpcCommand, oneshot::Sender<MpvIpcResponse>)>,
event_channel: broadcast::Sender<MpvIpcEvent>,
}
/// Commands that can be sent to [`MpvIpc`]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum MpvIpcCommand {
Command(Vec<String>),
@ -24,9 +33,11 @@ pub(crate) enum MpvIpcCommand {
Exit,
}
/// [`MpvIpc`]'s response to a [`MpvIpcCommand`].
#[derive(Debug, Clone)]
pub(crate) struct MpvIpcResponse(pub(crate) Result<Option<Value>, Error>);
/// A deserialized and partially parsed event from mpv.
#[derive(Debug, Clone)]
pub(crate) struct MpvIpcEvent(pub(crate) Value);
@ -88,10 +99,6 @@ impl MpvIpc {
property: &str,
value: Value,
) -> Result<Option<Value>, Error> {
// let str_value = match &value {
// Value::String(s) => s,
// v => &serde_json::to_string(&v).unwrap(),
// };
self.send_command(&[json!("set_property"), json!(property), value])
.await
}
@ -177,6 +184,9 @@ impl MpvIpc {
}
}
/// This function does the most basic JSON parsing and error handling
/// for status codes and errors that all responses from mpv are
/// expected to contain.
fn parse_mpv_response_data(value: Value) -> Result<Option<Value>, Error> {
log::trace!("Parsing mpv response data: {:?}", value);
let result = value

View File

@ -1,7 +1,13 @@
mod api;
mod api_extension;
#![doc = include_str!("../README.md")]
mod core_api;
mod error;
mod event_parser;
mod highlevel_api_extension;
mod ipc;
mod message_parser;
pub use api::*;
pub use api_extension::*;
pub use core_api::*;
pub use error::*;
pub use event_parser::*;
pub use highlevel_api_extension::*;

View File

@ -1,3 +1,5 @@
//! JSON parsing logic for command responses from [`MpvIpc`](crate::ipc::MpvIpc).
use std::collections::HashMap;
use serde_json::Value;
@ -134,61 +136,27 @@ pub(crate) fn json_map_to_hashmap(
}
pub(crate) fn json_array_to_vec(array: &[Value]) -> Vec<MpvDataType> {
let mut output: Vec<MpvDataType> = Vec::new();
if !array.is_empty() {
match array[0] {
Value::Array(_) => {
for entry in array {
if let Value::Array(ref a) = *entry {
output.push(MpvDataType::Array(json_array_to_vec(a)));
}
array
.into_iter()
.map(|entry| match entry {
Value::Array(a) => MpvDataType::Array(json_array_to_vec(&a)),
Value::Bool(b) => MpvDataType::Bool(*b),
Value::Number(n) => {
if n.is_u64() {
MpvDataType::Usize(n.as_u64().unwrap() as usize)
} else if n.is_f64() {
MpvDataType::Double(n.as_f64().unwrap())
} else {
panic!("unimplemented number");
}
}
Value::Bool(_) => {
for entry in array {
if let Value::Bool(ref b) = *entry {
output.push(MpvDataType::Bool(*b));
}
}
}
Value::Number(_) => {
for entry in array {
if let Value::Number(ref n) = *entry {
if n.is_u64() {
output.push(MpvDataType::Usize(n.as_u64().unwrap() as usize));
} else if n.is_f64() {
output.push(MpvDataType::Double(n.as_f64().unwrap()));
} else {
panic!("unimplemented number");
}
}
}
}
Value::Object(_) => {
for entry in array {
if let Value::Object(ref map) = *entry {
output.push(MpvDataType::HashMap(json_map_to_hashmap(map)));
}
}
}
Value::String(_) => {
for entry in array {
if let Value::String(ref s) = *entry {
output.push(MpvDataType::String(s.to_string()));
}
}
}
Value::Object(o) => MpvDataType::HashMap(json_map_to_hashmap(&o)),
Value::String(s) => MpvDataType::String(s.to_owned()),
Value::Null => {
unimplemented!();
}
}
}
output
})
.collect()
}
pub(crate) fn json_array_to_playlist(array: &[Value]) -> Vec<PlaylistEntry> {
@ -215,3 +183,137 @@ pub(crate) fn json_array_to_playlist(array: &[Value]) -> Vec<PlaylistEntry> {
}
output
}
#[cfg(test)]
mod test {
use super::*;
use crate::MpvDataType;
use serde_json::json;
use std::collections::HashMap;
#[test]
fn test_json_map_to_hashmap() {
let json = json!({
"array": [1, 2, 3],
"bool": true,
"double": 1.0,
"usize": 1,
"string": "string",
"object": {
"key": "value"
}
});
let mut expected = HashMap::new();
expected.insert(
"array".to_string(),
MpvDataType::Array(vec![
MpvDataType::Usize(1),
MpvDataType::Usize(2),
MpvDataType::Usize(3),
]),
);
expected.insert("bool".to_string(), MpvDataType::Bool(true));
expected.insert("double".to_string(), MpvDataType::Double(1.0));
expected.insert("usize".to_string(), MpvDataType::Usize(1));
expected.insert(
"string".to_string(),
MpvDataType::String("string".to_string()),
);
expected.insert(
"object".to_string(),
MpvDataType::HashMap(HashMap::from([(
"key".to_string(),
MpvDataType::String("value".to_string()),
)])),
);
assert_eq!(json_map_to_hashmap(&json.as_object().unwrap()), expected);
}
#[test]
#[should_panic]
fn test_json_map_to_hashmap_fail_on_null() {
json_map_to_hashmap(
json!({
"null": null
})
.as_object()
.unwrap(),
);
}
#[test]
fn test_json_array_to_vec() {
let json = json!([
[1, 2, 3],
true,
1.0,
1,
"string",
{
"key": "value"
}
]);
println!("{:?}", json.as_array().unwrap());
println!("{:?}", json_array_to_vec(&json.as_array().unwrap()));
let expected = vec![
MpvDataType::Array(vec![
MpvDataType::Usize(1),
MpvDataType::Usize(2),
MpvDataType::Usize(3),
]),
MpvDataType::Bool(true),
MpvDataType::Double(1.0),
MpvDataType::Usize(1),
MpvDataType::String("string".to_string()),
MpvDataType::HashMap(HashMap::from([(
"key".to_string(),
MpvDataType::String("value".to_string()),
)])),
];
assert_eq!(json_array_to_vec(&json.as_array().unwrap()), expected);
}
#[test]
#[should_panic]
fn test_json_array_to_vec_fail_on_null() {
json_array_to_vec(json!([null]).as_array().unwrap().as_slice());
}
#[test]
fn test_json_array_to_playlist() {
let json = json!([
{
"filename": "file1",
"title": "title1",
"current": true
},
{
"filename": "file2",
"title": "title2",
"current": false
}
]);
let expected = vec![
PlaylistEntry {
id: 0,
filename: "file1".to_string(),
title: "title1".to_string(),
current: true,
},
PlaylistEntry {
id: 1,
filename: "file2".to_string(),
title: "title2".to_string(),
current: false,
},
];
assert_eq!(json_array_to_playlist(&json.as_array().unwrap()), expected);
}
}

61
tests/integration.rs Normal file
View File

@ -0,0 +1,61 @@
use mpvipc::{Error, Mpv, MpvExt};
use std::path::Path;
use tokio::{
process::{Child, Command},
time::{sleep, timeout, Duration},
};
#[cfg(target_family = "unix")]
async fn spawn_headless_mpv() -> Result<(Child, Mpv), Error> {
let socket_path_str = format!("/tmp/mpv-ipc-{}", uuid::Uuid::new_v4());
let socket_path = Path::new(&socket_path_str);
let process_handle = Command::new("mpv")
.arg("--no-config")
.arg("--idle")
.arg("--no-video")
.arg("--no-audio")
.arg(format!(
"--input-ipc-server={}",
&socket_path.to_str().unwrap()
))
.spawn()
.expect("Failed to start mpv");
if timeout(Duration::from_millis(500), async {
while !&socket_path.exists() {
sleep(Duration::from_millis(10)).await;
}
})
.await
.is_err()
{
panic!("Failed to create mpv socket at {:?}", &socket_path);
}
let mpv = Mpv::connect(socket_path.to_str().unwrap()).await.unwrap();
Ok((process_handle, mpv))
}
#[tokio::test]
#[cfg(target_family = "unix")]
async fn test_get_mpv_version() {
let (mut proc, mpv) = spawn_headless_mpv().await.unwrap();
let version: String = mpv.get_property("mpv-version").await.unwrap();
assert!(version.starts_with("mpv"));
mpv.kill().await.unwrap();
proc.kill().await.unwrap();
}
#[tokio::test]
#[cfg(target_family = "unix")]
async fn test_set_property() {
let (mut proc, mpv) = spawn_headless_mpv().await.unwrap();
mpv.set_property("pause", true).await.unwrap();
let paused: bool = mpv.get_property("pause").await.unwrap();
assert_eq!(paused, true);
mpv.kill().await.unwrap();
proc.kill().await.unwrap();
}