diff --git a/Cargo.toml b/Cargo.toml index 956c986..b1eb002 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ description = "Idiomatic MPRIS D-Bus interface library" version = "3.0.0-alpha1" license = "Apache-2.0" edition = "2021" -rust-version = "1.56" +rust-version = "1.75" authors = ["Magnus Bergmark "] repository = "https://github.com/Mange/mpris-rs" readme = "README.md" @@ -18,12 +18,11 @@ maintenance = { status = "actively-developed" } # Enable zbus' tight tokio integration; in case you want to use this crate in a # Tokio-based app. tokio = ["zbus/tokio"] -# Enable serde support for metadata -serde = ["dep:serde"] [dependencies] -zbus = "3.4.0" -serde = { version = "1.0.164", optional = true } +zbus = "4.4.0" +serde ="1.0.164" +futures-util = "0.3.31" # Example dependencies [dev-dependencies] diff --git a/clippy.toml b/clippy.toml index 787620d..c115e91 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1 +1,2 @@ allow-private-module-inception = true +allow-unwrap-in-tests = true diff --git a/examples/list_players.rs b/examples/list_players.rs index bac6648..0eb0bff 100644 --- a/examples/list_players.rs +++ b/examples/list_players.rs @@ -1,10 +1,9 @@ -use mpris::Mpris; -use std::error::Error; +use mpris::{Mpris, MprisError}; #[async_std::main] -async fn main() -> Result<(), Box> { +async fn main() -> Result<(), MprisError> { let mpris = Mpris::new().await?; - for player in mpris.players().await? { + for player in mpris.all_players().await? { println!("{:?}", player); } Ok(()) diff --git a/examples/metadata.rs b/examples/metadata.rs index b59e9e7..3bcd3f3 100644 --- a/examples/metadata.rs +++ b/examples/metadata.rs @@ -1,12 +1,11 @@ -use mpris::{Mpris, Player}; -use std::error::Error; +use mpris::{Mpris, MprisError, Player}; #[async_std::main] -async fn main() -> Result<(), Box> { +async fn main() -> Result<(), MprisError> { let mpris = Mpris::new().await?; let mut total = 0; - for player in mpris.players().await? { + for player in mpris.all_players().await? { print_metadata(player).await?; total += 1; } @@ -18,7 +17,7 @@ async fn main() -> Result<(), Box> { Ok(()) } -async fn print_metadata(player: Player<'_>) -> Result<(), Box> { +async fn print_metadata(player: Player) -> Result<(), MprisError> { println!( "Player: {} ({})", player.identity().await?, diff --git a/src/duration.rs b/src/duration.rs new file mode 100644 index 0000000..b29e404 --- /dev/null +++ b/src/duration.rs @@ -0,0 +1,530 @@ +use std::{ + ops::{Add, Div, Mul, Sub}, + time::Duration, +}; + +use zbus::zvariant::{OwnedValue, Value}; + +use crate::{errors::InvalidMprisDuration, metadata::MetadataValue}; + +const MAX: u64 = i64::MAX as u64; + +/// A Mpris specific version of [`Duration`]. +/// +/// Contains a time duration in microseconds that's a non-negative [`i64`]. Technically the MPRIS +/// spec allows for time durations to be negative even though a song can't have a negative +/// length/position but this type doesn't allow them. +/// +/// ## Creation +/// +/// The simplest way to create this type is to create a [`Duration`] and covert it into it. +/// ``` +/// use std::time::Duration; +/// use mpris::MprisDuration; +/// +/// let m_dur = MprisDuration::new_from_duration(Duration::from_secs(10)); +/// ``` +/// +/// All of the [`TryFrom`] implementations for [`MprisDuration`] will fail if the value can't be +/// losslessly converted to a valid [`MprisDuration`]. In case you just want to create a valid +/// [`MprisDuration`] and don't are about about being lossless you can use the `new_from_*` methods. +/// ``` +/// use std::time::Duration; +/// use mpris::MprisDuration; +/// +/// let m_dur = MprisDuration::new_from_i64(-42_i64); +/// assert_eq!(m_dur.as_u64(), 0); +/// +/// let dur_big = MprisDuration::new_from_duration(Duration::from_secs(u64::MAX)); +/// assert_eq!(dur_big.as_i64(), i64::MAX) +/// ``` +/// ## Ops +/// [`MprisDuration`] implements [`Add`], [`Sub`], [`Mul`] and [`Div`] for itself, [`Duration`] and +/// [`u64`]. The values will stay valid. +#[derive( + Debug, + PartialEq, + Eq, + Clone, + Copy, + Hash, + PartialOrd, + Ord, + Default, + serde::Serialize, + serde::Deserialize, + zbus::zvariant::Type, +)] +#[serde(try_from = "i64", into = "i64")] +#[zvariant(signature = "x")] +pub struct MprisDuration(u64); + +impl MprisDuration { + /// Lossily creates a new valid [`MprisDuration`] from a [`u64`]. + /// + /// If the value is too big it will be reduced to [`i64::MAX`]. If you want a lossless + /// conversion that can fail you should use [`TryFrom`]. + pub fn new_from_u64(micros: u64) -> Self { + Self(micros.clamp(0, MAX)) + } + + /// Lossily creates a new valid [`MprisDuration`] from a [`i64`]. + /// + /// Negative values will be changed to `0`. If you want a lossless conversion that can fail you + /// should use [`TryFrom`]. + pub fn new_from_i64(micros: i64) -> Self { + Self(micros.clamp(0, i64::MAX) as u64) + } + + /// Lossily creates a new valid [`MprisDuration`] from a [`Duration`]. + /// + /// If [`Duration`]'s values as microseconds is too big it will be reduced to [`i64::MAX`]. If + /// you want a lossless conversion that can fail you should use [`TryFrom`]. + pub fn new_from_duration(duration: Duration) -> Self { + Self(duration.as_micros().clamp(0, MAX as u128) as u64) + } + + /// Creates a new [`MprisDuration`] with the biggest possible value + pub fn new_max() -> Self { + Self(MAX) + } + + /// Returns a [`u64`] equal to the [`MprisDuration`]'s value as microseconds + pub fn as_u64(&self) -> u64 { + self.0 + } + + /// Returns a [`i64`] equal to the [`MprisDuration`]'s value as microseconds + pub fn as_i64(&self) -> i64 { + self.0 as i64 + } +} + +impl From for Duration { + fn from(value: MprisDuration) -> Self { + Duration::from_micros(value.0) + } +} + +impl TryFrom for MprisDuration { + type Error = InvalidMprisDuration; + + fn try_from(value: Duration) -> Result { + u64::try_from(value.as_micros()) + .or(Err(InvalidMprisDuration::new_too_big()))? + .try_into() + } +} + +impl From for i64 { + fn from(value: MprisDuration) -> Self { + value.0 as i64 + } +} + +impl From for u64 { + fn from(value: MprisDuration) -> Self { + value.0 + } +} + +impl TryFrom for MprisDuration { + type Error = InvalidMprisDuration; + + fn try_from(value: i64) -> Result { + if value < 0 { + Err(InvalidMprisDuration::new_negative()) + } else { + Ok(Self(value as u64)) + } + } +} + +impl TryFrom for MprisDuration { + type Error = InvalidMprisDuration; + + fn try_from(value: u64) -> Result { + if value > MAX { + Err(InvalidMprisDuration::new_too_big()) + } else { + Ok(Self(value)) + } + } +} + +impl TryFrom for MprisDuration { + type Error = InvalidMprisDuration; + + fn try_from(value: MetadataValue) -> Result { + match value { + MetadataValue::SignedInt(int) => int.try_into(), + MetadataValue::UnsignedInt(int) => int.try_into(), + _ => Err(InvalidMprisDuration::expected( + "MetadataValue::SignedInt or MetadataValue::UnsignedInt", + )), + } + } +} + +impl TryFrom> for MprisDuration { + type Error = InvalidMprisDuration; + + fn try_from(value: Value<'_>) -> Result { + match value { + Value::U8(v) => Ok(Self::from(v)), + Value::U16(v) => Ok(Self::from(v)), + Value::U32(v) => Ok(Self::from(v)), + Value::U64(v) => Self::try_from(v), + Value::I16(v) => Self::try_from(v), + Value::I32(v) => Self::try_from(v), + Value::I64(v) => Self::try_from(v), + _ => Err(InvalidMprisDuration::expected("integer value")), + } + } +} + +impl TryFrom for MprisDuration { + type Error = InvalidMprisDuration; + + fn try_from(value: OwnedValue) -> Result { + Self::try_from(Value::from(value)) + } +} + +macro_rules! impl_math { + ($trait:ident, $method:ident, $sat:ident) => { + impl $trait for MprisDuration { + type Output = Self; + + fn $method(self, rhs: u64) -> Self::Output { + Self(self.0.$sat(rhs).clamp(0, MAX)) + } + } + + impl $trait<&u64> for MprisDuration { + type Output = Self; + + fn $method(self, rhs: &u64) -> Self::Output { + Self::$method(self, *rhs) + } + } + + impl $trait for MprisDuration { + type Output = Self; + + fn $method(self, rhs: MprisDuration) -> Self::Output { + Self::$method(self, rhs.as_u64()) + } + } + + impl $trait<&MprisDuration> for MprisDuration { + type Output = Self; + + fn $method(self, rhs: &MprisDuration) -> Self::Output { + Self::$method(self, rhs.as_u64()) + } + } + + impl $trait for MprisDuration { + type Output = Self; + + fn $method(self, rhs: Duration) -> Self::Output { + Self::$method(self, rhs.as_micros().clamp(0, MAX as u128) as u64) + } + } + + impl $trait<&Duration> for MprisDuration { + type Output = Self; + + fn $method(self, rhs: &Duration) -> Self::Output { + Self::$method(self, rhs.as_micros().clamp(0, MAX as u128) as u64) + } + } + }; +} + +macro_rules! impl_unsigned_small { + ($type:ty) => { + impl From<$type> for MprisDuration { + fn from(value: $type) -> Self { + Self(value as u64) + } + } + + impl TryFrom for $type { + type Error = std::num::TryFromIntError; + + fn try_from(value: MprisDuration) -> Result { + <$type>::try_from(value.0) + } + } + }; +} + +macro_rules! impl_signed_small { + ($type:ty) => { + impl TryFrom<$type> for MprisDuration { + type Error = InvalidMprisDuration; + + fn try_from(value: $type) -> Result { + if value < 0 { + Err(InvalidMprisDuration::new_negative()) + } else { + Ok(Self(value as u64)) + } + } + } + + impl TryFrom for $type { + type Error = std::num::TryFromIntError; + + fn try_from(value: MprisDuration) -> Result { + <$type>::try_from(value.0) + } + } + }; +} + +impl_math!(Mul, mul, saturating_mul); +impl_math!(Div, div, saturating_div); +impl_math!(Add, add, saturating_add); +impl_math!(Sub, sub, saturating_sub); + +impl_unsigned_small!(u8); +impl_unsigned_small!(u16); +impl_unsigned_small!(u32); + +impl_signed_small!(i8); +impl_signed_small!(i16); +impl_signed_small!(i32); + +#[cfg(test)] +mod mrpis_duration_tests { + use super::*; + + #[test] + fn new() { + assert_eq!(MprisDuration::new_max(), MprisDuration(MAX)); + assert_eq!(MprisDuration::new_from_u64(0), MprisDuration(0)); + assert_eq!(MprisDuration::default(), MprisDuration(0)); + assert_eq!( + MprisDuration::new_from_u64(u64::MAX), + MprisDuration::new_max() + ); + } + + #[test] + fn into_duration() { + assert_eq!( + Duration::from(MprisDuration::new_from_u64(0)), + Duration::from_micros(0) + ); + assert_eq!( + Duration::from(MprisDuration::new_from_u64(123456789)), + Duration::from_micros(123456789) + ); + assert_eq!( + Duration::from(MprisDuration::new_max()), + Duration::from_micros(MAX) + ); + } + + #[test] + fn try_from_duration() { + assert_eq!( + MprisDuration::try_from(Duration::default()), + Ok(MprisDuration::default()) + ); + assert_eq!( + MprisDuration::try_from(Duration::from_micros(MAX)), + Ok(MprisDuration::new_max()) + ); + + assert!(MprisDuration::try_from(Duration::from_micros(MAX + 1)).is_err()); + } + + #[test] + fn into_ints() { + let d = MprisDuration::default(); + assert_eq!(i64::from(d), 0); + assert_eq!(u64::from(d), 0); + let d_max = MprisDuration::new_max(); + assert_eq!(i64::from(d_max), i64::MAX); + assert_eq!(u64::from(d_max), MAX); + } + + #[test] + fn try_from_ints() { + assert!(MprisDuration::try_from(i64::MIN).is_err()); + assert_eq!(MprisDuration::try_from(0_i64), Ok(MprisDuration::default())); + assert_eq!( + MprisDuration::try_from(i64::MAX), + Ok(MprisDuration::new_max()) + ); + assert_eq!(MprisDuration::try_from(0_u64), Ok(MprisDuration::default())); + assert!(MprisDuration::try_from(MAX + 1).is_err()); + } + + macro_rules! gen_small_unsigned_test { + ($type:ty) => { + assert_eq!(MprisDuration::from(<$type>::MIN), MprisDuration(0)); + assert_eq!( + MprisDuration::from(<$type>::MAX), + MprisDuration(<$type>::MAX as u64) + ); + + assert_eq!(<$type>::try_from(MprisDuration(0)), Ok(0)); + assert_eq!( + <$type>::try_from(MprisDuration(<$type>::MAX as u64)), + Ok(<$type>::MAX) + ); + assert!(<$type>::try_from(MprisDuration::new_max()).is_err()); + }; + } + + macro_rules! gen_small_signed_test { + ($type:ty) => { + assert!(MprisDuration::try_from(<$type>::MIN).is_err()); + let zero: $type = 0; + assert_eq!(MprisDuration::try_from(zero), Ok(MprisDuration(0))); + assert_eq!( + MprisDuration::try_from(<$type>::MAX), + Ok(MprisDuration(<$type>::MAX as u64)) + ); + + assert_eq!(<$type>::try_from(MprisDuration(0)), Ok(0)); + assert_eq!( + <$type>::try_from(MprisDuration(<$type>::MAX as u64)), + Ok(<$type>::MAX) + ); + assert!(<$type>::try_from(MprisDuration::new_max()).is_err()); + }; + } + + #[test] + fn small_unsigned_conversions() { + gen_small_unsigned_test!(u8); + gen_small_unsigned_test!(u16); + gen_small_unsigned_test!(u32); + } + + #[test] + fn small_signed_conversions() { + gen_small_signed_test!(i8); + gen_small_signed_test!(i16); + gen_small_signed_test!(i32); + } + + #[test] + fn try_from_metadata_value() { + assert!(MprisDuration::try_from(MetadataValue::Boolean(false)).is_err()); + assert!(MprisDuration::try_from(MetadataValue::Float(0.0)).is_err()); + assert!(MprisDuration::try_from(MetadataValue::SignedInt(0)).is_ok()); + assert!(MprisDuration::try_from(MetadataValue::UnsignedInt(0)).is_ok()); + assert!(MprisDuration::try_from(MetadataValue::String(String::new())).is_err()); + assert!(MprisDuration::try_from(MetadataValue::Strings(vec![])).is_err()); + assert!(MprisDuration::try_from(MetadataValue::Unsupported).is_err()); + } +} + +#[cfg(test)] +mod ops_tests { + use super::*; + + macro_rules! gen_ops_tests { + ($type:expr) => { + assert_eq!( + MprisDuration::new_from_u64(1) * $type(10_u64), + MprisDuration::new_from_u64(10) + ); + #[allow(clippy::erasing_op)] + { + assert_eq!( + MprisDuration::new_from_u64(1) * $type(0_u64), + MprisDuration::new_from_u64(0) + ); + } + assert_eq!( + MprisDuration::new_max() * $type(2_u64), + MprisDuration::new_max() + ); + + assert_eq!( + MprisDuration::new_from_u64(0) / $type(1_u64), + MprisDuration::new_from_u64(0) + ); + assert_eq!( + MprisDuration::new_from_u64(10) / $type(3_u64), + MprisDuration::new_from_u64(10 / 3) + ); + assert_eq!( + MprisDuration::new_max() / $type(MAX), + MprisDuration::new_from_u64(1) + ); + assert_eq!( + MprisDuration::new_from_u64(1) / $type(MAX), + MprisDuration::new_from_u64(0) + ); + + assert_eq!( + MprisDuration::new_from_u64(0) + $type(1_u64), + MprisDuration::new_from_u64(1) + ); + assert_eq!( + MprisDuration::new_max() + $type(1_u64), + MprisDuration::new_max() + ); + + assert_eq!( + MprisDuration::new_from_u64(0) - $type(1_u64), + MprisDuration::new_from_u64(0) + ); + assert_eq!( + MprisDuration::new_from_u64(10) - $type(1_u64), + MprisDuration::new_from_u64(9) + ); + }; + } + + #[test] + fn u64() { + gen_ops_tests!(u64::from); + } + + #[test] + fn mpris_duration() { + gen_ops_tests!(MprisDuration::new_from_u64); + } + + #[test] + fn duration() { + gen_ops_tests!(Duration::from_micros); + } +} + +#[cfg(test)] +mod mpris_duration_serde_tests { + use super::*; + use serde_test::{assert_de_tokens_error, assert_tokens, Token}; + + const MIN_TOKENS: [Token; 1] = get_tokens(0); + const MAX_TOKENS: [Token; 1] = get_tokens(MAX as i64); + + const fn get_tokens(x: i64) -> [Token; 1] { + [Token::I64(x)] + } + + #[test] + fn ser_and_deser() { + assert_tokens(&MprisDuration::default(), &MIN_TOKENS); + assert_tokens(&MprisDuration::new_max(), &MAX_TOKENS); + } + + #[test] + fn invalid_deser() { + assert_de_tokens_error::( + &get_tokens(-1), + &InvalidMprisDuration::new_negative().0, + ); + } +} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..b7e9317 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,180 @@ +//! The module containing all of the errors. + +use std::fmt::Display; + +pub use zbus::Error; + +#[rustfmt::skip] +macro_rules! generate_error { + ($error:ident, $source:ident) => { + #[doc=concat!( + "Error for when [`", + stringify!($source), + "`](crate::", + stringify!($source), + ") ", + "failed to be created." + ) + ] + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct $error(pub(crate) String); + + impl $error { + #[allow(dead_code)] + pub(crate) fn expected(expected: &str) -> Self { + Self(format!(r"invalid value, expected: {expected}")) + } + } + + impl Display for $error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } + } + + impl From for $error { + fn from(value: String) -> Self { + Self(value) + } + } + + impl From<&str> for $error { + fn from(value: &str) -> Self { + Self(value.to_string()) + } + } + + impl From<$error> for Error { + fn from(_: $error) -> Self { + zbus::zvariant::Error::IncorrectType.into() + } + } + + impl From<$error> for zbus::zvariant::Error { + fn from(_: $error) -> Self { + Self::IncorrectType + } + } + }; +} + +generate_error!(InvalidPlaybackStatus, PlaybackStatus); +generate_error!(InvalidLoopStatus, LoopStatus); +generate_error!(InvalidTrackID, TrackID); +generate_error!(InvalidMprisDuration, MprisDuration); +generate_error!(InvalidMetadataValue, MetadataValue); +generate_error!(InvalidMetadata, Metadata); +generate_error!(InvalidPlaylist, Playlist); +generate_error!(InvalidPlaylistOrdering, PlaylistOrdering); + +impl InvalidMprisDuration { + pub(crate) fn new_too_big() -> Self { + Self("can't create MprisDuration, value too big".to_string()) + } + + pub(crate) fn new_negative() -> Self { + Self("can't create MprisDuration, value is negative".to_string()) + } +} + +/// The main error type for this library, created when when interacting with a +/// [`Player`](crate::Player). +/// +/// This enum contains variants for all of the various ways that something can go wrong. The 2 +/// errors you'll encounter the most are [`DbusError`](Self::DbusError) and +/// [`ParseError`](Self::ParseError). +#[derive(Debug, PartialEq, Clone)] +pub enum MprisError { + /// An error occurred while talking to the D-Bus. + DbusError(Error), + + /// Failed to parse a string received from the [`Player`][crate::Player]. This means that the + /// [`Player`][crate::Player] replied with unexpected data. + ParseError(String), + + /// The player doesn't implement the required interface/method/signal. + Unsupported, + + /// One of the given arguments has an invalid value. + InvalidArgument(String), + + /// Some other unexpected error occurred. + Miscellaneous(String), +} + +impl MprisError { + pub(crate) fn track_id_is_no_track() -> Self { + Self::InvalidArgument( + "/org/mpris/MediaPlayer2/TrackList/NoTrack is not a valid value".to_owned(), + ) + } +} + +impl From for MprisError { + fn from(value: Error) -> Self { + match value { + Error::InterfaceNotFound | Error::Unsupported => Self::Unsupported, + _ => Self::DbusError(value), + } + } +} + +impl From for MprisError { + fn from(value: zbus::zvariant::Error) -> Self { + MprisError::DbusError(value.into()) + } +} + +impl From for MprisError { + fn from(value: zbus::fdo::Error) -> Self { + Self::DbusError(value.into()) + } +} + +impl From for MprisError { + fn from(value: InvalidPlaybackStatus) -> Self { + Self::ParseError(value.0) + } +} + +impl From for MprisError { + fn from(value: InvalidLoopStatus) -> Self { + Self::ParseError(value.0) + } +} + +impl From for MprisError { + fn from(value: InvalidTrackID) -> Self { + Self::ParseError(value.0) + } +} + +impl From for MprisError { + fn from(value: InvalidMprisDuration) -> Self { + Self::ParseError(value.0) + } +} + +impl From for MprisError { + fn from(value: InvalidMetadata) -> Self { + Self::ParseError(value.0) + } +} + +impl From for MprisError { + fn from(value: InvalidPlaylistOrdering) -> Self { + Self::ParseError(value.0) + } +} + +impl From for MprisError { + fn from(value: InvalidPlaylist) -> Self { + Self::ParseError(value.0) + } +} + +impl From for MprisError { + fn from(value: String) -> Self { + Self::Miscellaneous(value) + } +} diff --git a/src/lib.rs b/src/lib.rs index 3974bd1..3525844 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,23 +1,707 @@ -use zbus::Connection; +#![warn( + clippy::print_stdout, + missing_docs, + clippy::todo, + clippy::unwrap_used, + rustdoc::unescaped_backticks +)] +#![deny( + missing_debug_implementations, + missing_copy_implementations, + trivial_casts, + trivial_numeric_casts, + unsafe_code, + unreachable_pub, + unstable_features, + unused_import_braces, + unused_qualifications +)] -mod metadata; +//! # mpris +//! +//! `mpris` is a library for dealing with [MPRIS2][spec]-compatible media players over D-Bus. +//! +//! This would mostly apply to the Linux-ecosystem which is a heavy user of D-Bus. +//! +//! ## Getting started +//! +//! Some hints on how to use this library: +//! +//! 1. Look at the examples under `examples/` in the repo +//! 2. Look at the [`Mpris`] struct +//! 3. Get the first player and make it start playing: +//! ```no_run +//! use mpris::Mpris; +//! +//! #[async_std::main] +//! async fn main() { +//! let mpris = Mpris::new().await.expect("couldn't connect to D-Bus"); +//! let player = match mpris.find_first().await { +//! Ok(result) => match result { +//! Some(player) => player, +//! None => { +//! println!("No player found"); +//! return; +//! } +//! }, +//! Err(err) => { +//! println!("Error occured: {:?}", err); +//! return; +//! } +//! }; +//! match player.play().await { +//! Ok(_) => println!("Made the player play"), +//! Err(err) => println!("Error occured: {:?}", err), +//! } +//! } +//! ``` +//! +//! ## Runtime compatibility +//! +//! The examples will be using [`async_std`][std] but this library does not require a specific +//! runtime. When used with a runtime that isn't [`Tokio`][tokio] it will spawn a thread for the +//! background tasks. If you want to prevent that you should "tick" the internal executor with your +//! runtime like this: +//! +//! ```ignore +//! use mpris::Mpris; +//! +//! #[async_std::main] +//! async fn main() { +//! let mpris = Mpris::new_no_executor().await.unwrap(); +//! async_std::task::spawn(mpris.get_executor_loop().expect("was started with no executor")); +//! +//! // The rest of your code here +//! } +//! ``` +//! +//! [spec]: https://specifications.freedesktop.org/mpris-spec/latest/ +//! [std]: https://docs.rs/async-std/latest/async_std/ +//! [tokio]: https://docs.rs/tokio/latest/tokio/ + +use std::cell::OnceCell; +use std::collections::VecDeque; +use std::fmt::{Debug, Display}; +use std::future::Future; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use futures_util::stream::{FusedStream, Stream, TryStreamExt}; +use zbus::zvariant::{OwnedValue, Value}; +use zbus::{ + names::{BusName, WellKnownName}, + Connection, +}; + +mod duration; +pub mod errors; +pub mod metadata; mod player; +mod playlist; mod proxies; +pub(crate) mod serde_util; + +use errors::*; -pub use metadata::Metadata; +use crate::proxies::DBusProxy; +pub use duration::MprisDuration; +#[doc(inline)] +pub use errors::MprisError; +#[cfg(not(feature = "tokio"))] +pub use internal_executor::ExecutorLoop; +#[doc(inline)] +pub use metadata::{Metadata, MetadataValue, TrackID}; pub use player::Player; +pub use playlist::{Playlist, PlaylistOrdering}; +pub(crate) const MPRIS2_PREFIX: &str = "org.mpris.MediaPlayer2."; + +type PlayerFuture = Pin> + Send>>; + +/// The main struct of the library. Used to find [`Player`]s on a D-Bus connection. +/// +/// All find methods first sort alphabetically by the players' [well known Bus Name][busname] and if +/// any of the [`Player`]s fails to initialize they will immediately return with an [`Err`]. If you +/// want to get all of the [`Player`]s even if one fails to initialize you should use +/// [`stream_players()`][Self::stream_players] and handle the errors. +/// +/// # Find methods return types +/// - [Ok]\([Some]\([Player]\)\): No error happened and a [`Player`] was found +/// - [Ok]\([None]\): No error happened but no [`Player`] was found +/// - [Err]\([MprisError]\): Error occurred while searching, most likely while +/// communicating with the D-Bus server +/// +/// [busname]: https://dbus.freedesktop.org/doc/dbus-tutorial.html#bus-names +#[derive(Clone)] pub struct Mpris { connection: Connection, + #[cfg(not(feature = "tokio"))] + internal_executor: bool, + // OnceCell is needed to allow disabling the internal executor since creating DBusProxy without + // ticking would just hang + // Option is not used because that would require making most of the functions &mut self + dbus_proxy: OnceCell>, } impl Mpris { - pub async fn new() -> Result> { + /// Creates a new [`Mpris`] struct by connecting to the session D-Bus server. + /// + /// Use [`new_from_connection`](Self::new_from_connection) if you want to provide the D-Bus + /// connection yourself. + #[cfg_attr( + not(feature = "tokio"), + doc = "\n\nSee also: [`new_no_executor()`][Self::new_no_executor]." + )] + pub async fn new() -> Result { let connection = Connection::session().await?; - Ok(Self { connection }) + Ok(Self::new_from_connection(connection)) + } + + /// Creates a new [`Mpris`] instance with the given connection. + /// + #[cfg_attr( + not(feature = "tokio"), + doc = "When creating `Mpris` through this it is assumed that the internal executor is set to `true`.\n\n" + )] + + /// Use [`new`](Self::new) if you don't have a need to provide the D-Bus connection yourself. + pub fn new_from_connection(connection: Connection) -> Self { + Self { + connection, + #[cfg(not(feature = "tokio"))] + internal_executor: true, + dbus_proxy: OnceCell::new(), + } + } + + /// Creates a new [`Mpris`] instance with the internal executor disabled. + /// + /// See [`Runtime compatibility`][crate#runtime-compatibility] for details. + /// + /// See also: [`new()`][Self::new], [`get_executor_loop()`][Self::get_executor_loop] and + /// [`ExecutorLoop`]. + #[cfg(not(feature = "tokio"))] + pub async fn new_no_executor() -> Result { + let connection = zbus::conn::Builder::session()? + .internal_executor(false) + .build() + .await?; + + Ok(Self { + connection, + internal_executor: false, + dbus_proxy: OnceCell::new(), + }) + } + + /// Returns the [`ExecutorLoop`] if the internal executor is disabled. + /// + /// See [`Runtime compatibility`][crate#runtime-compatibility] for details. + #[cfg(not(feature = "tokio"))] + pub fn get_executor_loop(&self) -> Option { + if !self.internal_executor { + Some(ExecutorLoop::new(self.get_connection())) + } else { + None + } + } + + /// Returns the DBusProxy by setting it if not yet initialized + pub(crate) async fn get_dbus_proxy(&self) -> Result<&DBusProxy<'static>, MprisError> { + loop { + match self.dbus_proxy.get() { + Some(proxy) => return Ok(proxy), + None => { + let proxy = DBusProxy::new(&self.connection).await?; + let _ = self.dbus_proxy.set(proxy); + } + }; + } + } + + /// Gets the [`Connection`] that is used. + pub fn get_connection(&self) -> Connection { + self.connection.clone() + } + + /// Gets a reference to the [`Connection`] that is used. + pub fn get_connection_ref(&self) -> &Connection { + &self.connection + } + + /// Returns the first found [`Player`] regardless of state. + pub async fn find_first(&self) -> Result, MprisError> { + match self.all_player_bus_names().await?.into_iter().next() { + Some(bus) => Ok(Some(Player::new(self, bus).await?)), + None => Ok(None), + } + } + + /// Tries to find the "active" [`Player`] in the connection. + /// + /// This method will try to determine which player a user is most likely to use. First it will + /// look for a player with the playback status [`Playing`](PlaybackStatus::Playing), then for a + /// [`Paused`](PlaybackStatus::Paused), then one with any track metadata, after that it will + /// just return the first it finds. + pub async fn find_active(&self) -> Result, MprisError> { + let mut players = self.stream_players().await?; + if players.is_terminated() { + return Ok(None); + } + + let mut first_paused: Option = None; + let mut first_with_track: Option = None; + let mut first_found: Option = None; + + while let Some(player) = players.try_next().await? { + let player_status = player.playback_status().await?; + + if player_status == PlaybackStatus::Playing { + return Ok(Some(player)); + } + + if first_paused.is_none() && player_status == PlaybackStatus::Paused { + first_paused.replace(player); + } else if first_with_track.is_none() && !player.raw_metadata().await?.is_empty() { + first_with_track.replace(player); + } else if first_found.is_none() { + first_found.replace(player); + } + } + + Ok(first_paused.or(first_with_track).or(first_found)) + } + + /// Looks for a [`Player`] by it's MPRIS [`Identity`][identity]. + /// + /// See also [`Player::identity()`]. + /// + /// [identity]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Media_Player.html#Property:Identity + pub async fn find_by_name( + &self, + name: &str, + case_sensitive: bool, + ) -> Result, MprisError> { + let mut players = self.stream_players().await?; + if players.is_terminated() { + return Ok(None); + } + while let Some(player) = players.try_next().await? { + let identity = player.identity().await?; + if case_sensitive { + if identity == name { + return Ok(Some(player)); + } + } else if identity.to_lowercase() == name.to_lowercase() { + return Ok(Some(player)); + } + } + Ok(None) + } + + /// Finds all available [`Player`]s in the connection. + /// + /// Will return an empty [`Vec`] if there are no players. + pub async fn all_players(&self) -> Result, MprisError> { + let bus_names = self.all_player_bus_names().await?; + let mut players = Vec::with_capacity(bus_names.len()); + for player_name in bus_names { + players.push(Player::new(self, player_name).await?); + } + Ok(players) + } + + /// Gets all of the BusNames that start with the [`MPRIS2_PREFIX`] + async fn all_player_bus_names(&self) -> Result>, MprisError> { + let mut names: Vec = self + .get_dbus_proxy() + .await? + .list_names() + .await? + .into_iter() + .filter(|name| name.starts_with(MPRIS2_PREFIX)) + // We got the bus name from the D-Bus server so unchecked is fine + .map(|name| BusName::from(WellKnownName::from_string_unchecked(name))) + .collect(); + names.sort_unstable_by_key(|n| n.to_lowercase()); + Ok(names) + } + + /// Creates a [`PlayerStream`] which implements the [`Stream`] trait. + /// + /// For more details see [`PlayerStream`]'s documentation. + pub async fn stream_players(&self) -> Result { + let buses = self.all_player_bus_names().await?; + Ok(PlayerStream::new( + self.get_connection(), + self.get_dbus_proxy().await?.clone(), + buses, + )) + } +} + +impl Debug for Mpris { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut s = f.debug_struct("Mpris"); + s.field("connection", &format_args!("Connection {{ .. }}")); + #[cfg(not(feature = "tokio"))] + s.field("internal_executor", &self.internal_executor); + s.finish_non_exhaustive() + } +} + +#[cfg(not(feature = "tokio"))] +mod internal_executor { + use std::fmt::Debug; + use std::future::Future; + use std::pin::Pin; + use std::task::{Context, Poll}; + + use zbus::Connection; + + /// A future that ticks the executor in a endless loop. + /// + /// Created with [`Mpris::get_executor_loop()`][crate::Mpris::get_executor_loop]. + /// + ///
You have to spawn this as a new task with your runtime or everything will + /// hang.
+ /// + /// See [`Runtime compatibility`][crate#runtime-compatibility] for details. + pub struct ExecutorLoop { + fut: Pin + Send>>, + } + + impl ExecutorLoop { + pub(crate) fn new(connection: Connection) -> Self { + let fut = Box::pin(async move { + loop { + connection.executor().tick().await + } + }); + Self { fut } + } + } + + impl Future for ExecutorLoop { + type Output = (); + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.fut.as_mut().poll(cx) + } + } + + impl Debug for ExecutorLoop { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TickLoop").finish_non_exhaustive() + } + } +} + +/// Lazily returns the [`Player`]s on the connection. +/// +/// Created with [`Mpris::stream_players()`][Mpris::stream_players]. +/// +/// Implements the [`Stream`] trait which is the async version of [`Iterator`]. It is recommended to +/// use the [`futures_util`] or [`futures_lite`][lite] crate which provide useful traits for streams. +/// +/// Note that [`PlayerStream`] will only yield the [`Player`]s that were present when it was created. +/// +/// ```no_run +/// use futures_util::StreamExt; +/// use mpris::Mpris; +/// +/// #[async_std::main] +/// async fn main() { +/// let mpris = Mpris::new().await.unwrap(); +/// let mut stream = mpris.stream_players().await.unwrap(); +/// +/// while let Some(result) = stream.next().await { +/// match result { +/// Ok(player) => {}, // Do something with Player +/// Err(err) => {}, // Deal with the error +/// } +/// } +/// } +/// ``` +/// +/// [lite]: https://docs.rs/futures-lite/latest/futures_lite/ +pub struct PlayerStream { + connection: Connection, + dbus_proxy: DBusProxy<'static>, + buses: VecDeque>, + cur_future: Option, +} + +impl PlayerStream { + /// Creates a new [`PlayerStream`]. + /// + /// There should be no need to use this directly and instead you should use + /// [`Mpris::stream_players`]. + fn new( + connection: Connection, + dbus_proxy: DBusProxy<'static>, + buses: Vec>, + ) -> Self { + let buses = VecDeque::from(buses); + Self { + connection, + dbus_proxy, + buses, + cur_future: None, + } + } +} + +impl Stream for PlayerStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + loop { + match self.cur_future.as_mut() { + Some(fut) => match fut.as_mut().poll(cx) { + Poll::Ready(player) => { + self.cur_future = None; + self.buses.pop_front(); + return Poll::Ready(Some(player)); + } + Poll::Pending => return Poll::Pending, + }, + None => match self.buses.front() { + Some(bus) => { + self.cur_future = Some(Box::pin(Player::new_internal( + self.connection.clone(), + self.dbus_proxy.clone(), + bus.clone(), + ))) + } + None => return Poll::Ready(None), + }, + } + } + } + + fn size_hint(&self) -> (usize, Option) { + let l = self.buses.len(); + (l, Some(l)) + } +} + +impl FusedStream for PlayerStream { + fn is_terminated(&self) -> bool { + self.buses.is_empty() + } +} + +impl Debug for PlayerStream { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PlayerStream") + .field("connection", &format_args!("Connection {{ .. }}")) + .field("buses", &self.buses) + .field( + "cur_future", + &self.cur_future.as_ref().map(|_| &self.buses[0]), + ) + .finish() + } +} + +/// The [`Player`]'s playback status. +/// +/// See: [MPRIS2 specification about `PlaybackStatus`][playback_status] +/// +/// [playback_status]: +/// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Enum:Playback_Status +#[derive(Debug, PartialEq, Eq, Copy, Clone, Hash)] +pub enum PlaybackStatus { + /// A track is currently playing. + Playing, + /// A track is currently paused. + Paused, + /// There is no track currently playing. + Stopped, +} + +impl ::std::str::FromStr for PlaybackStatus { + type Err = InvalidPlaybackStatus; + + fn from_str(string: &str) -> Result { + match string { + "Playing" => Ok(Self::Playing), + "Paused" => Ok(Self::Paused), + "Stopped" => Ok(Self::Stopped), + _ => Err(InvalidPlaybackStatus::from(string)), + } + } +} + +impl PlaybackStatus { + /// Returns it's value as a &[str] + pub fn as_str(&self) -> &'static str { + match self { + PlaybackStatus::Playing => "Playing", + PlaybackStatus::Paused => "Paused", + PlaybackStatus::Stopped => "Stopped", + } + } +} + +impl TryFrom> for PlaybackStatus { + type Error = InvalidPlaybackStatus; + + fn try_from(value: Value<'_>) -> Result { + match value { + Value::Str(s) => s.parse(), + _ => Err(InvalidPlaybackStatus::expected("Value::Str")), + } + } +} + +impl TryFrom for PlaybackStatus { + type Error = InvalidPlaybackStatus; + + fn try_from(value: OwnedValue) -> Result { + Self::try_from(Value::from(value)) + } +} + +impl Display for PlaybackStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// A [`Player`]'s looping status. +/// +/// See: [MPRIS2 specification about `Loop_Status`][loop_status] +/// +/// [loop_status]: +/// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Enum:Loop_Status +#[derive(Debug, PartialEq, Eq, Copy, Clone, Hash)] +pub enum LoopStatus { + /// The playback will stop when there are no more tracks to play. + None, + + /// The current track will start again from the beginning once it has finished playing. + Track, + + /// The playback loops through a list of tracks. + Playlist, +} + +impl ::std::str::FromStr for LoopStatus { + type Err = InvalidLoopStatus; + + fn from_str(string: &str) -> Result { + match string { + "None" => Ok(LoopStatus::None), + "Track" => Ok(LoopStatus::Track), + "Playlist" => Ok(LoopStatus::Playlist), + _ => Err(InvalidLoopStatus::from(string)), + } + } +} + +impl LoopStatus { + /// Returns it's value as a &[str] + pub fn as_str(&self) -> &'static str { + match self { + LoopStatus::None => "None", + LoopStatus::Track => "Track", + LoopStatus::Playlist => "Playlist", + } + } +} + +impl From for Value<'static> { + fn from(value: LoopStatus) -> Value<'static> { + Value::Str(value.as_str().into()) + } +} + +impl TryFrom> for LoopStatus { + type Error = InvalidLoopStatus; + + fn try_from(value: Value<'_>) -> Result { + match value { + Value::Str(s) => s.parse(), + _ => Err(InvalidLoopStatus::expected("Value::Str")), + } + } +} + +impl TryFrom for LoopStatus { + type Error = InvalidLoopStatus; + + fn try_from(value: OwnedValue) -> Result { + Self::try_from(Value::from(value)) + } +} + +impl Display for LoopStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +#[cfg(test)] +mod status_enums_tests { + use super::*; + + #[test] + fn valid_playback_status() { + assert_eq!("Playing".parse(), Ok(PlaybackStatus::Playing)); + assert_eq!("Paused".parse(), Ok(PlaybackStatus::Paused)); + assert_eq!("Stopped".parse(), Ok(PlaybackStatus::Stopped)); + } + + #[test] + fn invalid_playback_status() { + assert_eq!( + "".parse::(), + Err(InvalidPlaybackStatus::from("")) + ); + assert_eq!( + "playing".parse::(), + Err(InvalidPlaybackStatus::from("playing")) + ); + assert_eq!( + "wrong".parse::(), + Err(InvalidPlaybackStatus::from("wrong")) + ); + } + + #[test] + fn playback_status_as_str() { + assert_eq!(PlaybackStatus::Playing.as_str(), "Playing"); + assert_eq!(PlaybackStatus::Paused.as_str(), "Paused"); + assert_eq!(PlaybackStatus::Stopped.as_str(), "Stopped"); + } + + #[test] + fn valid_loop_status() { + assert_eq!("None".parse(), Ok(LoopStatus::None)); + assert_eq!("Track".parse(), Ok(LoopStatus::Track)); + assert_eq!("Playlist".parse(), Ok(LoopStatus::Playlist)); + } + + #[test] + fn invalid_loop_status() { + assert_eq!("".parse::(), Err(InvalidLoopStatus::from(""))); + assert_eq!( + "track".parse::(), + Err(InvalidLoopStatus::from("track")) + ); + assert_eq!( + "wrong".parse::(), + Err(InvalidLoopStatus::from("wrong")) + ); } - pub async fn players(&self) -> Result, Box> { - player::all(&self.connection).await + #[test] + fn loop_status_as_str() { + assert_eq!(LoopStatus::None.as_str(), "None"); + assert_eq!(LoopStatus::Track.as_str(), "Track"); + assert_eq!(LoopStatus::Playlist.as_str(), "Playlist"); } } diff --git a/src/metadata.rs b/src/metadata.rs index 043c0d0..fb0b2be 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -1,7 +1,9 @@ +//! The module containing the metadata related types. +#[allow(clippy::module_inception)] mod metadata; mod track_id; mod values; -pub use self::metadata::Metadata; +pub use self::metadata::{Metadata, MetadataIntoIter, RawMetadata}; pub use self::track_id::TrackID; pub use self::values::MetadataValue; diff --git a/src/metadata/metadata.rs b/src/metadata/metadata.rs index e0333a3..bdaa92a 100644 --- a/src/metadata/metadata.rs +++ b/src/metadata/metadata.rs @@ -1,73 +1,705 @@ -use std::{collections::HashMap, time::Duration}; +use std::{collections::HashMap, iter::FusedIterator}; + +use zbus::zvariant::{Error as ZError, OwnedValue, Type, Value}; use super::{MetadataValue, TrackID}; +use crate::{errors::InvalidMetadata, MprisDuration}; + +/// HashMap returned from DBus +type DBusMetadata = HashMap; +type InnerRawMetadata = HashMap; + +/// A struct that represents the raw version of [`Metadata`]. +/// +/// It's a simple wrapper around [HashMap]<[String], [MetadataValue]>. It should act +/// like a [`HashMap`] but it can be easily converted into and from one using the [`From`] traits or +/// [`into_inner()`][Self::into_inner]. +/// +/// Can be obtained from [`Player::raw_metadata()`][crate::Player::raw_metadata]. +#[derive(Clone, PartialEq, Default, serde::Serialize, serde::Deserialize, Type)] +#[serde(transparent)] +#[zvariant(signature = "a{sv}")] +pub struct RawMetadata(InnerRawMetadata); + +impl RawMetadata { + /// Creates a new empty [`RawMetadata`]. + pub fn new() -> Self { + Self(HashMap::new()) + } + + /// Turns [`RawMetadata`] into [HashMap]<[String], [MetadataValue]>. + pub fn into_inner(self) -> InnerRawMetadata { + self.0 + } +} + +impl std::ops::Deref for RawMetadata { + type Target = InnerRawMetadata; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for RawMetadata { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl From for RawMetadata { + fn from(value: DBusMetadata) -> Self { + Self( + value + .into_iter() + .map(|(k, v)| (k, MetadataValue::from(v))) + .collect(), + ) + } +} + +impl From for InnerRawMetadata { + fn from(value: RawMetadata) -> Self { + value.0 + } +} + +impl From for RawMetadata { + fn from(value: InnerRawMetadata) -> Self { + Self(value) + } +} + +impl FromIterator<(String, MetadataValue)> for RawMetadata { + fn from_iter>(iter: T) -> Self { + Self(HashMap::from_iter(iter)) + } +} + +impl TryFrom> for RawMetadata { + type Error = ZError; + + fn try_from(value: Value<'_>) -> Result { + InnerRawMetadata::try_from(value).map(Self) + } +} + +impl TryFrom for RawMetadata { + type Error = ZError; + + fn try_from(value: OwnedValue) -> Result { + InnerRawMetadata::try_from(value).map(Self) + } +} + +impl IntoIterator for RawMetadata { + type Item = (String, MetadataValue); + + type IntoIter = std::collections::hash_map::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl PartialEq for RawMetadata { + fn eq(&self, other: &InnerRawMetadata) -> bool { + &self.0 == other + } +} + +impl std::fmt::Debug for RawMetadata { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_map().entries(self.iter()).finish() + } +} + +/// Macro that auto implements useful things for Metadata without needing to repeat the fields every time +/// while preserving documentation for the fields +/// +/// The generated things are: +/// - `Metadata::new()` +/// - `Metadata::is_empty()` +/// - `Metadata::is_valid()' +/// - `Metadata::get_metadata_key()` which lets you get the key for a given field as a str +/// - `TryFrom> for Metadata` +/// - `Metadata::from_raw_lossy()` which is similar to the above but wrong types just get discarded +/// - `From for HashMap` +/// - `IntoIterator for Metadata` +/// +/// The macro expects a structure like this +/// ```text +/// #[derive(Debug, Clone, Default, PartialEq)] +/// struct Example { +/// "key" => key_field: Vec, +/// "prefix:otherKey" => other_key_field: String, +/// [...] +/// "last_key" => last_key_field: f64, +/// field_name_for_hashmap, +/// } +/// ``` +macro_rules! gen_metadata_struct { + ($(#[$struct_meta:meta])* + struct $name:ident { + $($(#[$field_meta:meta])* + $key:literal => $field:ident : $type:ty),*, $others_name:ident $(,)? + }) => { + + // Creates the actual struct + $(#[$struct_meta])* + pub struct $name { + $( + $(#[$field_meta])* + #[doc=""] + #[doc=concat!("The `", stringify!($key), "` field from the guidelines.")] + pub $field: Option<$type> + ),*, + /// The rest of the metadata not specified in the guidelines. + pub $others_name: RawMetadata, + } + + impl $name { + /// Creates a new empty [`Metadata`]. + /// + /// Same as using `Metadata::default()`. + pub fn new() -> Self { + Self { + $($field: None),*, + $others_name: RawMetadata::new(), + } + } + + /// Checks if it contains any metadata. + pub fn is_empty(&self) -> bool { + $(self.$field.is_none())&&* + && self.$others_name.is_empty() + } + + /// Checks if it's valid. + /// + /// See [here][Self#validity] for the requirements. + pub fn is_valid(&self) -> bool { + if self.is_empty() { + true + } else { + self.track_id.is_some() + } + } + + /// Gets the field name as specified in the [guide] for the given [`Metadata`] field. + /// + /// Returns [`None`] if such a [`Metadata`] field doesn't exist. + /// ``` + /// use mpris::Metadata; + /// + /// assert_eq!(Metadata::get_metadata_key("lyrics"), Some("xesam:asText")); + /// assert_eq!(Metadata::get_metadata_key("invalid_field"), None); + /// ``` + /// [guide]: https://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata/ + pub fn get_metadata_key(field: &str) -> Option<&str> { + match field { + $(stringify!($field) => Some($key)),*, + _ => None + } + } + + /// Lossily converts from [`RawMetadata`] into [`Metadata`] + /// + /// Similar to [TryFrom]<[RawMetadata]> but the requirements mentioned + /// [here][Self#validity] are not checked so it can't fail. If a field mentioned in the + /// [guidelines] has the wrong type it will be turned into [`None`]. This is useful when + /// used together with [`Player::raw_metadata()`][crate::Player::raw_metadata] in the + /// case your player doesn't follow the [guidelines] and for example sends over some + /// data in the wrong type. + /// ```no_run + /// use mpris::{Metadata, Mpris}; + /// + /// async_std::task::block_on(async { + /// let mpris = Mpris::new().await.unwrap(); + /// let player = mpris.find_first().await.unwrap().unwrap(); + /// let meta = Metadata::from_raw_lossy(player.raw_metadata().await.unwrap()); + /// }) + /// ``` + /// + /// ``` + /// use std::collections::HashMap; + /// use mpris::{Metadata, metadata::RawMetadata}; + /// + /// let mut raw_meta = RawMetadata::new(); + /// // Wrong type for title + /// raw_meta.insert(String::from("xesam:title"), 0_u64.into()); + /// raw_meta.insert(String::from("xesam:comment"), String::from("Some comment").into()); + /// let wrong_meta = Metadata::from_raw_lossy(raw_meta); + /// + /// // Creates fine even though the field had the wrong type and track_id is not present + /// assert!(!wrong_meta.is_valid()); + /// assert!(wrong_meta.track_id.is_none()); + /// // Wrong value got turned into None + /// assert!(wrong_meta.title.is_none()) + /// ``` + /// + /// [guidelines]: https://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata/ + pub fn from_raw_lossy(mut raw: RawMetadata) -> Self { + Self { + $($field: raw.remove($key).and_then(|v| <$type>::try_from(v).ok())),*, + $others_name: raw + } + } + } + + impl IntoIterator for $name { + type Item = (String, Option); + type IntoIter = MetadataIntoIter; + + fn into_iter(mut self) -> Self::IntoIter { + // Turns the fields into Vec<&'static str, Option> with they key as the str + let fields = vec![ + $(($key, self.$field.take().map(MetadataValue::from))),* + ]; + MetadataIntoIter::new(fields, self.$others_name) + } + } + + // From for HashMap + // Simply adds the fields to the HashMap using the specified key + impl From<$name> for RawMetadata { + fn from(mut value: $name) -> Self { + let mut map = value.$others_name; + $(if let Some(v) = value.$field.take() { + map.insert(String::from($key), MetadataValue::from(v)); + })* + map + } + } -#[derive(Debug, Default, Clone)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct Metadata { - pub album_artists: Option>, - pub album_name: Option, - pub art_url: Option, - pub artists: Option>, - pub audio_bpm: Option, - pub auto_rating: Option, - pub comments: Option>, - pub composers: Option>, - pub content_created: Option, - pub disc_number: Option, - pub first_used: Option, - pub genres: Option>, - pub last_used: Option, - pub length: Option, - pub lyricists: Option>, - pub lyrics: Option, - pub title: Option, - pub track_id: Option, - pub track_number: Option, - pub url: Option, - pub use_count: Option, - pub user_rating: Option, -} - -macro_rules! extract { - ($hash:ident, $key:expr, $f:expr) => { - extract(&mut $hash, $key, $f) - }; -} - -fn extract(raw: &mut HashMap, key: &str, f: F) -> Option -where - F: FnOnce(MetadataValue) -> Option, -{ - raw.remove(key).and_then(f) -} - -impl From> for Metadata { - fn from(mut raw: HashMap) -> Self { - Metadata { - album_artists: extract!(raw, "xesam:albumArtist", MetadataValue::into_strings), - album_name: extract!(raw, "xesam:album", MetadataValue::into_nonempty_string), - art_url: extract!(raw, "mpris:artUrl", MetadataValue::into_nonempty_string), - artists: extract!(raw, "xesam:artist", MetadataValue::into_strings), - audio_bpm: extract!(raw, "xesam:audioBPM", MetadataValue::into_u64), - auto_rating: extract!(raw, "xesam:autoRating", MetadataValue::into_float), - comments: extract!(raw, "xesam:comment", MetadataValue::into_strings), - composers: extract!(raw, "xesam:composer", MetadataValue::into_strings), - content_created: extract!(raw, "xesam:contentCreated", MetadataValue::into_string), - disc_number: extract!(raw, "xesam:discNumber", MetadataValue::into_u64), - first_used: extract!(raw, "xesam:firstUsed", MetadataValue::into_string), - genres: extract!(raw, "xesam:genre", MetadataValue::into_strings), - last_used: extract!(raw, "xesam:lastUsed", MetadataValue::into_string), - length: extract!(raw, "mpris:length", MetadataValue::into_u64) - .map(Duration::from_micros), - lyricists: extract!(raw, "xesam:lyricist", MetadataValue::into_strings), - lyrics: extract!(raw, "xesam:asText", MetadataValue::into_string), - title: extract!(raw, "xesam:title", MetadataValue::into_nonempty_string), - track_id: extract!(raw, "mpris:trackid", |v| TrackID::try_from(v).ok()), - track_number: extract!(raw, "xesam:trackNumber", MetadataValue::into_u64), - url: extract!(raw, "xesam:url", MetadataValue::into_nonempty_string), - use_count: extract!(raw, "xesam:useCount", MetadataValue::into_u64), - user_rating: extract!(raw, "xesam:userRating", MetadataValue::into_float), + // TryFrom> for Metadata + // Removes the given key from the HashMap tries to turn it into the target type. + // Fails if MetadataValue is of the wrong type for the field or if mpris:trackid" is missing + impl TryFrom for $name { + type Error = InvalidMetadata; + + fn try_from(mut raw: RawMetadata) -> Result { + if raw.is_empty() { + return Ok(Self::new()); + } else if !raw.contains_key("mpris:trackid") { + return Err(InvalidMetadata::from("metadata doesn't contain the mpris:trackid key")); + } + + Ok(Self { + $( + $field: { + match raw.remove($key).map(<$type>::try_from) { + Some(v) => Some(v.map_err(|e| InvalidMetadata::from(format!("{} for {}", e.0, $key)))?), + None => None, + } + } + ),*, + $others_name: raw + }) + } + } + + impl TryFrom for $name { + type Error = InvalidMetadata; + + fn try_from(value: DBusMetadata) -> Result { + let raw = RawMetadata::from(value); + Self::try_from(raw) + } + } +}} + +gen_metadata_struct!( + /// A struct that represents metadata for a track. + /// + /// It follows the [MPRIS v2 metadata guidelines][guide]. It can be obtained from + /// [`Player::metadata()`][crate::Player::metadata] but it can also be created from any + /// [`RawMetadata`] that meets the requirements mentioned below. The metadata fields included in + /// the guidelines are assigned to struct fields for easier access and are type checked while + /// all other metadata fields are held in the [`others`][Self::others] field as [`RawMetadata`]. + /// + /// # Validity + /// + /// For [`Metadata`] to be valid it has to be empty or it needs to at least contain the + /// `"mpris:trackid"` field ([`track_id`][Metadata::track_id]) which has to be a valid + /// [`TrackID`]. All the other fields are optional but they need to be the right type. + /// [TryFrom]<[RawMetadata]> will fail if these requirements are not met. The + /// [`others`][Self::others] field is not checked in any way. + /// + /// ``` + /// use std::collections::HashMap; + /// use mpris::{Metadata, TrackID, metadata::RawMetadata}; + /// + /// let mut raw_meta = RawMetadata::new(); + /// + /// // Empty is valid + /// assert!(Metadata::try_from(raw_meta.clone()).is_ok()); + /// + /// // Adding any fields without adding track_id will fail + /// raw_meta.insert(String::from("some_field"), String::from("Some value").into()); + /// assert!(Metadata::try_from(raw_meta.clone()).is_err()); + /// + /// // A valid track_id is present but a field from the guidelines has a wrong type + /// raw_meta.insert( + /// String::from("mpris:trackid"), + /// TrackID::try_from("/valid/path").unwrap().into(), + /// ); + /// raw_meta.insert(String::from("xesam:trackNumber"), String::new().into()); + /// assert!(Metadata::try_from(raw_meta.clone()).is_err()); + /// + /// // If we remove the invalid type it will be valid + /// raw_meta.remove("xesam:trackNumber"); + /// assert!(Metadata::try_from(raw_meta).is_ok()); + /// ``` + /// + /// # Miscellaneous features + /// + /// - Can be turned into [`RawMetadata`] using [Into]<[RawMetadata]> + /// - Implements [`IntoIterator`], see [`MetadataIntoIter`] for details + /// - Can be lossily converted from [`RawMetadata`] by using + /// [`from_raw_lossy()`][Self::from_raw_lossy] + /// + /// [guide]: https://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata/ + /// [object_path]: https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-marshaling-object-path + #[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)] + #[serde(into = "RawMetadata", try_from = "RawMetadata")] + struct Metadata { + /// The album artist(s). + "xesam:albumArtist" => album_artists: Vec, + /// The album name. + "xesam:album" => album_name: String, + /// The location of an image representing the track or album. Clients should not assume this + /// will continue to exist when the media player stops giving out the URL. + "mpris:artUrl" => art_url: String, + /// The track artist(s). + "xesam:artist" => artists: Vec, + /// The speed of the music, in beats per minute. + "xesam:audioBPM" => audio_bpm: u64, + /// An automatically-generated rating, based on things such as how often it has been played. + /// This should be in the range 0.0 to 1.0. + "xesam:autoRating" => auto_rating: f64, + /// A (list of) freeform comment(s). + "xesam:comment" => comments: Vec, + /// The composer(s) of the track. + "xesam:composer" => composers: Vec, + /// When the track was created. Usually only the year component will be useful. + "xesam:contentCreated" => content_created: String, + /// The disc number on the album that this track is from. + "xesam:discNumber" => disc_number: u64, + /// When the track was first played. + "xesam:firstUsed" => first_used: String, + /// The genre(s) of the track. + "xesam:genre" => genres: Vec, + /// When the track was last played. + "xesam:lastUsed" => last_used: String, + /// The duration of the track in microseconds. + "mpris:length" => length: MprisDuration, + /// The lyricist(s) of the track. + "xesam:lyricist" => lyricists: Vec, + /// The track lyrics. + "xesam:asText" => lyrics: String, + /// The track title. + "xesam:title" => title: String, + /// A unique identity for this track within the context of an MPRIS object. + "mpris:trackid" => track_id: TrackID, + /// The track number on the album disc. + "xesam:trackNumber" => track_number: u64, + /// The location of the media file. + "xesam:url" => url: String, + /// The number of times the track has been played. + "xesam:useCount" => use_count: u64, + /// A user-specified rating. This should be in the range 0.0 to 1.0. + "xesam:userRating" => user_rating: f64, + others, + } +); + +/// [`Iterator`] over the fields of [`Metadata`]. +/// +/// Yields the field name as a [`String`] and [Option]<[MetadataValue]> containing the +/// value if present. The [`RawMetadata`] from the [`others`][Metadata::others] field is also +/// included and values from it will always be [Some]\([MetadataValue]\). +/// ``` +/// use mpris::Metadata; +/// +/// let meta = Metadata::new(); +/// for (field, value) in meta { +/// // Do something +/// } +/// ``` +/// **Note**: the field names from this iterator are the actual field names of the [`Metadata`] +/// struct. If you want the field names from the [guidelines] you can use +/// [`get_metadata_key()`][Metadata::get_metadata_key] together with this iterator: +/// ``` +/// use mpris::Metadata; +/// +/// let meta = Metadata::new(); +/// for (field, value) in meta { +/// let field_key = match Metadata::get_metadata_key(&field) { +/// Some(s) => s.to_string(), +/// None => field, +/// }; +/// // Do something +/// } +/// ``` +/// +/// [guidelines]: https://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata/ +#[derive(Debug)] +pub struct MetadataIntoIter { + values: std::vec::IntoIter<(&'static str, Option)>, + map: std::collections::hash_map::IntoIter, +} + +impl MetadataIntoIter { + fn new(fields: Vec<(&'static str, Option)>, map: RawMetadata) -> Self { + Self { + values: fields.into_iter(), + map: map.into_iter(), + } + } +} + +impl Iterator for MetadataIntoIter { + type Item = (String, Option); + + fn next(&mut self) -> Option { + match self.values.next() { + Some((k, v)) => Some((k.to_string(), v)), + None => self.map.next().map(|(k, v)| (k, Some(v))), + } + } + + fn size_hint(&self) -> (usize, Option) { + let l = self.values.len() + self.map.len(); + (l, Some(l)) + } +} + +impl ExactSizeIterator for MetadataIntoIter {} +impl FusedIterator for MetadataIntoIter {} + +#[cfg(test)] +mod metadata_tests { + use super::*; + + #[test] + fn empty_new_default() { + let empty = Metadata { + album_artists: None, + album_name: None, + art_url: None, + artists: None, + audio_bpm: None, + auto_rating: None, + comments: None, + composers: None, + content_created: None, + disc_number: None, + first_used: None, + genres: None, + last_used: None, + length: None, + lyricists: None, + lyrics: None, + title: None, + track_id: None, + track_number: None, + url: None, + use_count: None, + user_rating: None, + others: RawMetadata::new(), + }; + assert_eq!(empty, Metadata::default()); + assert_eq!(empty, Metadata::new()) + } + + #[test] + fn is_empty() { + let mut m = Metadata::new(); + assert!(m.is_empty()); + + let mut field = m.clone(); + field.disc_number = Some(0); + assert!(!field.is_empty()); + + m.others + .insert("test".to_string(), MetadataValue::Boolean(false)); + assert!(!m.is_empty()); + + m.others.remove("test"); + assert!(m.is_empty()); + } + + #[test] + fn is_valid() { + let mut meta = Metadata::new(); + assert!(meta.is_valid()); + meta.album_name = Some(String::from("Album Name")); + assert!(!meta.is_valid()); + meta.track_id = Some(TrackID::no_track()); + assert!(meta.is_valid()); + } + + #[test] + fn default_back_and_forth() { + let original = Metadata::new(); + assert_eq!( + Metadata::try_from(RawMetadata::from(original.clone())), + Ok(original) + ) + } + + #[test] + fn try_from_raw() { + let raw_metadata = RawMetadata::from_iter([ + ("xesam:albumArtist".to_string(), vec![String::new()].into()), + ("xesam:album".to_string(), String::new().into()), + ("mpris:artUrl".to_string(), String::new().into()), + ("xesam:artist".to_string(), vec![String::new()].into()), + ("xesam:audioBPM".to_string(), 0_i64.into()), + ("xesam:autoRating".to_string(), 0.0.into()), + ("xesam:comment".to_string(), vec![String::new()].into()), + ("xesam:composer".to_string(), vec![String::new()].into()), + ("xesam:contentCreated".to_string(), String::new().into()), + ("xesam:discNumber".to_string(), 0_i64.into()), + ("xesam:firstUsed".to_string(), String::new().into()), + ("xesam:genre".to_string(), vec![String::new()].into()), + ("xesam:lastUsed".to_string(), String::new().into()), + ("mpris:length".to_string(), MprisDuration::default().into()), + ("xesam:lyricist".to_string(), vec![String::new()].into()), + ("xesam:asText".to_string(), String::new().into()), + ("xesam:title".to_string(), String::new().into()), + ("mpris:trackid".to_string(), TrackID::no_track().into()), + ("xesam:trackNumber".to_string(), 0_i64.into()), + ("xesam:url".to_string(), String::new().into()), + ("xesam:useCount".to_string(), 0_i64.into()), + ("xesam:userRating".to_string(), 0.0.into()), + ("other".to_string(), MetadataValue::Unsupported), + ]); + let meta = Metadata::try_from(raw_metadata); + let manual_meta = Metadata { + album_artists: Some(vec![String::new()]), + album_name: Some(String::new()), + art_url: Some(String::new()), + artists: Some(vec![String::new()]), + audio_bpm: Some(0), + auto_rating: Some(0.0), + comments: Some(vec![String::new()]), + composers: Some(vec![String::new()]), + content_created: Some(String::new()), + disc_number: Some(0), + first_used: Some(String::new()), + genres: Some(vec![String::new()]), + last_used: Some(String::new()), + length: Some(MprisDuration::default()), + lyricists: Some(vec![String::new()]), + lyrics: Some(String::new()), + title: Some(String::new()), + track_id: Some(TrackID::no_track()), + track_number: Some(0), + url: Some(String::new()), + use_count: Some(0), + user_rating: Some(0.0), + others: RawMetadata::from_iter([(String::from("other"), MetadataValue::Unsupported)]), + }; + + assert_eq!(meta, Ok(manual_meta)); + } + + #[test] + fn try_from_raw_fail() { + let mut map = RawMetadata::new(); + + // Wrong type + map.insert("xesam:autoRating".to_string(), true.into()); + let m = Metadata::try_from(map.clone()); + assert!(m.is_err()); + + // Correct type but no TrackID + map.insert("xesam:autoRating".to_string(), 0.0.into()); + let m = Metadata::try_from(map.clone()); + assert!(m.is_err()); + + map.insert("mpris:trackid".to_string(), TrackID::no_track().into()); + let m = Metadata::try_from(map); + assert!(m.is_ok()); + } + + #[test] + fn equality() { + let mut first = Metadata::new(); + first.auto_rating = Some(0.0); + first.others.insert(String::from("test"), true.into()); + + let mut second = Metadata::new(); + second.auto_rating = Some(0.0); + assert_ne!(first, second.clone()); + + second.others.insert(String::from("test"), true.into()); + assert_eq!(first, second); + } +} + +#[cfg(test)] +mod metadata_iterator_tests { + use super::*; + + #[test] + fn empty() { + let iter = Metadata::new().into_iter(); + let (left, right) = iter.size_hint(); + assert_eq!(Some(left), right); + assert_eq!(left, 22); + + for (_, v) in iter { + assert!(v.is_none()); } } } + +#[cfg(test)] +mod raw_metadata { + use super::*; + + #[test] + fn new_is_default() { + let manual = RawMetadata(HashMap::new()); + assert_eq!(manual, RawMetadata::new()); + assert_eq!(manual, RawMetadata::default()); + } + + #[test] + fn deref() { + let mut meta = RawMetadata::new(); + assert!(meta.is_empty()); + meta.insert(String::from("Key"), MetadataValue::Unsupported); + assert_eq!(meta["Key"], MetadataValue::Unsupported); + assert!(!meta.is_empty()); + meta.clear(); + assert!(meta.is_empty()) + } + + #[test] + fn from_hash_map() { + let map: HashMap = + HashMap::from_iter([(String::from("Some"), OwnedValue::from(0_u64))]); + let meta = RawMetadata::from(map); + assert_eq!(meta.get("Some"), Some(&MetadataValue::UnsignedInt(0))); + assert_eq!(meta.get("Other"), None); + } + + #[test] + fn iter_and_eq() { + let values = [ + (String::from("Bool"), MetadataValue::Boolean(false)), + (String::from("Number"), MetadataValue::UnsignedInt(0)), + ( + String::from("String"), + MetadataValue::String(String::from("Value")), + ), + ]; + let meta = RawMetadata::from_iter(values.clone()); + let map = HashMap::from_iter(values); + assert_eq!(meta, map); + } +} diff --git a/src/metadata/track_id.rs b/src/metadata/track_id.rs index 2e5e060..71f5a47 100644 --- a/src/metadata/track_id.rs +++ b/src/metadata/track_id.rs @@ -1,70 +1,216 @@ use std::ops::Deref; -use zbus::zvariant::{OwnedValue, Value}; +use zbus::zvariant::{ObjectPath, OwnedObjectPath, OwnedValue, Type, Value}; use super::MetadataValue; +use crate::errors::InvalidTrackID; -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] -pub struct TrackID(String); +/// A struct that represents a valid MPRIS Track_Id. +/// +/// > Unique track identifier. +/// > If the media player implements the TrackList interface and allows the same track to appear +/// > multiple times in the tracklist, this must be unique within the scope of the tracklist. +/// +/// This type is checked on creation. It must be a [valid D-Bus object path][object_path] and it +/// _technically_ can't begin with `"/org/mpris"` besides the special [`NO_TRACK`][Self::NO_TRACK] +/// value but most players don't follow this rule so it is ignored. [`is_valid()`][Self::is_valid] +/// will still check for it. +/// +/// See [this link for the details][track_id]. +/// +/// [track_id]: +/// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Simple-Type:Track_Id +/// [object_path]: +/// https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-marshaling-object-path +#[derive(Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, Type)] +#[serde(into = "String", try_from = "String")] +#[zvariant(signature = "o")] +pub struct TrackID(OwnedObjectPath); + +impl TrackID { + /// The special "NoTrack" value + pub const NO_TRACK: &'static str = "/org/mpris/MediaPlayer2/TrackList/NoTrack"; + + /// Tries to create a new [`TrackID`] + /// + /// This is the same as using [TryFrom]<[String]> + pub fn new(id: String) -> Result { + Self::try_from(id) + } + + /// Creates a [`TrackID`] with the special [`NO_TRACK`][Self::NO_TRACK] value. + pub fn no_track() -> Self { + // We know it's a valid path so it's safe to skip the check + Self(OwnedObjectPath::from( + ObjectPath::from_static_str_unchecked(Self::NO_TRACK), + )) + } + + /// Checks if [`TrackID`] is the special [`NO_TRACK`][Self::NO_TRACK] value. + pub fn is_no_track(&self) -> bool { + self.as_str() == Self::NO_TRACK + } + + /// Checks if [`TrackID`] is valid. + /// + /// This also checks if the object path doesn't start with `/org/mpris` which gets ignored on + /// creation. + /// ``` + /// use mpris::TrackID; + /// + /// // Object path that follows the extra rule + /// let track = TrackID::try_from("/valid/path").expect("won't panic"); + /// assert!(track.is_valid()); + /// + /// // Will be created but is _technically_ not valid + /// let wrong_track = TrackID::try_from("/org/mpris/wrong").expect("won't panic"); + /// assert!(!wrong_track.is_valid()); + /// ``` + pub fn is_valid(&self) -> bool { + check_start(self.deref()).is_ok() + } + + /// Gets the D-Bus object path value as a &[`str`]. + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + /// Creates a borrowed [`ObjectPath`] for this [`TrackID`]. + pub fn as_object_path(&self) -> ObjectPath<'_> { + self.0.as_ref() + } + + #[cfg(test)] + pub(crate) fn from_str_unchecked(s: &'static str) -> Self { + Self::from(ObjectPath::from_static_str_unchecked(s)) + } +} + +impl AsRef for TrackID { + fn as_ref(&self) -> &OwnedObjectPath { + &self.0 + } +} + +fn check_start(s: T) -> Result +where + T: Deref, +{ + if s.starts_with("/org/mpris") && s.deref() != TrackID::NO_TRACK { + Err(InvalidTrackID::from( + r#"TrackID can't start with "/org/mpris""#, + )) + } else { + Ok(s) + } +} + +impl std::fmt::Debug for TrackID { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl Ord for TrackID { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.as_str().cmp(other.as_str()) + } +} + +impl PartialOrd for TrackID { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} impl TryFrom<&str> for TrackID { - type Error = (); + type Error = InvalidTrackID; fn try_from(value: &str) -> Result { - if value.is_empty() || value == "/org/mpris/MediaPlayer2/TrackList/NoTrack" { - Err(()) - } else { - Ok(TrackID(value.to_owned())) + match OwnedObjectPath::try_from(value) { + Ok(o) => Ok(Self(o)), + Err(e) => { + if let zbus::zvariant::Error::Message(s) = e { + Err(InvalidTrackID(s)) + } else { + // ObjectValue only creates Serde errors which get converted into + // zbus::zvariant::Error::Message + unreachable!("ObjectPath should only return Message errors") + } + } + } + } +} + +impl TryFrom for TrackID { + type Error = InvalidTrackID; + + fn try_from(value: String) -> Result { + match OwnedObjectPath::try_from(value) { + Ok(o) => Ok(Self(o)), + Err(e) => { + if let zbus::zvariant::Error::Message(s) = e { + Err(InvalidTrackID(s)) + } else { + // ObjectValue only creates Serde errors which get converted into + // zbus::zvariant::Error::Message + unreachable!("ObjectPath should only return Message errors") + } + } } } } impl TryFrom for TrackID { - type Error = (); + type Error = InvalidTrackID; fn try_from(value: MetadataValue) -> Result { match value { - MetadataValue::String(s) => s.as_str().try_into(), - _ => Err(()), + MetadataValue::String(s) => s.try_into(), + MetadataValue::Strings(mut s) if s.len() == 1 => { + s.pop().expect("length should be 1").try_into() + } + MetadataValue::TrackID(t) => Ok(t), + _ => Err(InvalidTrackID::expected("String or TrackID")), } } } impl TryFrom for TrackID { - type Error = (); + type Error = InvalidTrackID; fn try_from(value: OwnedValue) -> Result { - match value.deref() { - Value::Str(s) => s.as_str().try_into(), - Value::ObjectPath(path) => path.as_str().try_into(), - _ => Err(()), - } + Self::try_from(Value::from(value)) } } -impl TryFrom<&OwnedValue> for TrackID { - type Error = (); +impl TryFrom> for TrackID { + type Error = InvalidTrackID; - fn try_from(value: &OwnedValue) -> Result { - match value.deref() { - Value::Str(s) => s.as_str().try_into(), - Value::ObjectPath(path) => path.as_str().try_into(), - _ => Err(()), + fn try_from(value: Value) -> Result { + match value { + Value::Str(s) => Self::try_from(s.as_str()), + Value::ObjectPath(path) => Ok(Self::from(path)), + _ => Err(InvalidTrackID::expected("Str or ObjectPath")), } } } -impl<'a> TryFrom> for TrackID { - type Error = (); +impl From for Value<'static> { + fn from(value: TrackID) -> Self { + Self::ObjectPath(value.0.into()) + } +} + +impl From for TrackID { + fn from(value: OwnedObjectPath) -> Self { + Self(value) + } +} - fn try_from(value: Value) -> Result { - match value { - Value::Str(s) => s.as_str().try_into(), - Value::ObjectPath(path) => path.as_str().try_into(), - _ => Err(()), - } +impl From> for TrackID { + fn from(value: ObjectPath) -> Self { + Self(OwnedObjectPath::from(value)) } } @@ -76,20 +222,144 @@ impl Deref for TrackID { } } -#[cfg(all(test, feature = "serde"))] +impl From for ObjectPath<'_> { + fn from(value: TrackID) -> Self { + value.0.into_inner() + } +} + +impl From for OwnedObjectPath { + fn from(value: TrackID) -> Self { + value.0 + } +} + +impl From for String { + fn from(value: TrackID) -> Self { + value.0.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_track() { + let track = TrackID::no_track(); + let manual = TrackID::from_str_unchecked(TrackID::NO_TRACK); + + assert!(track.is_no_track()); + assert!(manual.is_no_track()); + assert_eq!(track, manual); + } + + #[test] + fn check_start_test() { + assert!(check_start("/some/path").is_ok()); + assert!(check_start("A").is_ok()); + assert!(check_start("").is_ok()); + assert!(check_start("/org/mpris").is_err()); + assert!(check_start("/org/mpris/more/path").is_err()); + assert!(check_start(TrackID::NO_TRACK).is_ok()); + } + + #[test] + fn valid_track_id() { + assert_eq!(TrackID::try_from("/"), Ok(TrackID::from_str_unchecked("/"))); + assert_eq!( + TrackID::try_from("/some/path"), + Ok(TrackID::from_str_unchecked("/some/path")) + ); + + assert_eq!( + TrackID::try_from("/".to_string()), + Ok(TrackID::from_str_unchecked("/")) + ); + assert_eq!( + TrackID::try_from("/some/path".to_string()), + Ok(TrackID::from_str_unchecked("/some/path")) + ); + } + + #[test] + fn invalid_track_id() { + assert!(TrackID::try_from("").is_err()); + assert!(TrackID::try_from("//some/path").is_err()); + assert!(TrackID::try_from("/some.path").is_err()); + assert!(TrackID::try_from("path").is_err()); + + assert!(TrackID::try_from("".to_string()).is_err()); + assert!(TrackID::try_from("//some/path".to_string()).is_err()); + assert!(TrackID::try_from("/some.path".to_string()).is_err()); + assert!(TrackID::try_from("path".to_string()).is_err()); + } + + #[test] + fn from_object_path() { + assert_eq!( + TrackID::from(ObjectPath::from_str_unchecked("/valid/path")), + TrackID::from_str_unchecked("/valid/path") + ); + assert_eq!( + TrackID::from(OwnedObjectPath::from(ObjectPath::from_str_unchecked( + "/valid/path" + ))), + TrackID::from_str_unchecked("/valid/path") + ); + } + + #[test] + fn from_metadata_value() { + let valid_track = Ok(TrackID::from_str_unchecked("/valid/path")); + assert!(TrackID::try_from(MetadataValue::Boolean(true)).is_err()); + assert!(TrackID::try_from(MetadataValue::Float(0.0)).is_err()); + assert!(TrackID::try_from(MetadataValue::SignedInt(0)).is_err()); + assert!(TrackID::try_from(MetadataValue::UnsignedInt(0)).is_err()); + assert_eq!( + TrackID::try_from(MetadataValue::String(String::from("/valid/path"))), + valid_track, + ); + assert!(TrackID::try_from(MetadataValue::Strings(vec![])).is_err()); + assert_eq!( + TrackID::try_from(MetadataValue::Strings(vec![String::from("/valid/path")])), + valid_track + ); + assert!( + TrackID::try_from(MetadataValue::Strings(vec![String::from("/valid/path"); 2])) + .is_err() + ); + assert_eq!( + TrackID::try_from(MetadataValue::TrackID(valid_track.clone().unwrap())), + valid_track + ); + assert!(TrackID::try_from(MetadataValue::Unsupported).is_err()); + } + + #[test] + fn is_valid() { + assert!(TrackID::try_from("/regular/path").unwrap().is_valid()); + assert!(!TrackID::try_from("/org/mpris").unwrap().is_valid()); + assert!(!TrackID::try_from("/org/mpris/invalid/path") + .unwrap() + .is_valid()); + } +} + +#[cfg(test)] mod serde_tests { use super::*; - use serde_test::{assert_de_tokens, assert_tokens, Token}; + use serde_test::{assert_de_tokens, assert_ser_tokens, Token}; #[test] fn test_serialization() { - let track_id = TrackID("/foo/bar".to_owned()); - assert_tokens(&track_id, &[Token::String("/foo/bar")]); + let track_id = TrackID::from_str_unchecked("/foo/bar"); + assert_ser_tokens(&track_id, &[Token::Str("/foo/bar")]); } #[test] fn test_deserialization() { - let track_id = TrackID("/foo/bar".to_owned()); - assert_de_tokens(&track_id, &[Token::String("/foo/bar")]); + let track_id = TrackID::from_str_unchecked("/foo/bar"); + assert_de_tokens(&track_id, &[Token::Str("/foo/bar")]); } } diff --git a/src/metadata/values.rs b/src/metadata/values.rs index 6a9d9ec..a1ec33b 100644 --- a/src/metadata/values.rs +++ b/src/metadata/values.rs @@ -1,45 +1,85 @@ -use zbus::zvariant::Value; - -/* -* Subset of DBus data types that are commonly used in MPRIS metadata, and a boolean variant as it -* seems likely to be used in some custom metadata. -* -* See https://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata/ -*/ -#[derive(Debug, Clone)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +use zbus::zvariant::{OwnedValue, Value}; + +use super::TrackID; +use crate::errors::InvalidMetadataValue; +use crate::serde_util::deser_no_fail; + +/// Subset of [DBus data types][dbus_types] that are commonly used in MPRIS metadata. +/// +/// See [this link][meta_spec] for examples of metadata values. +/// +/// Note that 16-bit and 32-bit integers get turned into their 64-bit version for convenience. +/// +/// [dbus_types]: https://dbus.freedesktop.org/doc/dbus-specification.html#type-system +/// [meta_spec]: https://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata/ +#[derive(Debug, Clone, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)] +#[serde(untagged)] +#[allow(missing_docs)] pub enum MetadataValue { Boolean(bool), - Float(f64), SignedInt(i64), UnsignedInt(u64), + Float(f64), String(String), Strings(Vec), + TrackID(TrackID), + #[serde(deserialize_with = "deser_no_fail")] Unsupported, } impl MetadataValue { - pub fn into_string(self) -> Option { - if let MetadataValue::String(s) = self { - Some(s) - } else { - None - } - } - + /// Tries to turn itself into a non-empty [`String`] + /// + /// Will succeed if it's a non-empty [`String`](Self::String) variant or a [`Strings`](Self::Strings) + /// variant which only contains 1 non-empty [`String`] pub fn into_nonempty_string(self) -> Option { - self.into_string() + String::try_from(self) + .ok() .and_then(|s| if s.is_empty() { None } else { Some(s) }) } + /// Tries to turn itself into a [`i64`] + /// + /// Will succeed if it's a [`SignedInt`](Self::SignedInt) or a + /// [`UnsignedInt`](Self::UnsignedInt) variant. Note that in the second case it will change the + /// value so that it fits into a [`i64`]. If you don't want the value to get changed you should + /// use the [`TryInto`] method. + /// ``` + /// use mpris::MetadataValue; + /// + /// let m = MetadataValue::UnsignedInt(u64::MAX); + /// // into_i64 decreases the number so that it fits + /// assert_eq!(m.into_i64(), Some(i64::MAX)); + /// + /// let m = MetadataValue::UnsignedInt(u64::MAX); + /// // TryFrom fails if it's too big + /// assert!(i64::try_from(m).is_err()); + /// ``` pub fn into_i64(self) -> Option { match self { MetadataValue::SignedInt(i) => Some(i), - MetadataValue::UnsignedInt(i) => Some(0i64.saturating_add_unsigned(i)), + MetadataValue::UnsignedInt(i) => Some(0_i64.saturating_add_unsigned(i)), _ => None, } } + /// Tries to turn itself into a [`u64`] + /// + /// Will succeed if it's a [`UnsignedInt`](Self::UnsignedInt) or a + /// [`SignedInt`](Self::SignedInt) variant. Note that in the second case it will change the + /// value to `0` if it's negative. If you don't want the value to get changed you should use the + /// [`TryInto`] method. + /// ``` + /// use mpris::MetadataValue; + /// + /// let m = MetadataValue::SignedInt(-1); + /// // into_u64 changes negative numbers to 0 + /// assert_eq!(m.into_u64(), Some(0)); + /// + /// let m = MetadataValue::SignedInt(-1); + /// // TryFrom fails if it's negative + /// assert!(u64::try_from(m).is_err()); + /// ``` pub fn into_u64(self) -> Option { match self { MetadataValue::SignedInt(i) if i < 0 => Some(0), @@ -48,21 +88,11 @@ impl MetadataValue { _ => None, } } +} - pub fn into_float(self) -> Option { - if let MetadataValue::Float(f) = self { - Some(f) - } else { - None - } - } - - pub fn into_strings(self) -> Option> { - match self { - MetadataValue::Strings(v) => Some(v), - MetadataValue::String(s) => Some(vec![s]), - _ => None, - } +impl From for MetadataValue { + fn from(value: OwnedValue) -> Self { + Self::from(Value::from(value)) } } @@ -72,21 +102,21 @@ impl<'a> From> for MetadataValue { Value::Bool(v) => MetadataValue::Boolean(v), Value::I16(v) => MetadataValue::SignedInt(v as i64), Value::I32(v) => MetadataValue::SignedInt(v as i64), - Value::I64(v) => MetadataValue::SignedInt(v as i64), + Value::I64(v) => MetadataValue::SignedInt(v), Value::U16(v) => MetadataValue::UnsignedInt(v as u64), Value::U32(v) => MetadataValue::UnsignedInt(v as u64), - Value::U64(v) => MetadataValue::UnsignedInt(v as u64), + Value::U64(v) => MetadataValue::UnsignedInt(v), Value::U8(v) => MetadataValue::UnsignedInt(v as u64), Value::F64(v) => MetadataValue::Float(v), Value::Str(v) => MetadataValue::String(v.to_string()), Value::Signature(v) => MetadataValue::String(v.to_string()), - Value::ObjectPath(v) => MetadataValue::String(v.to_string()), + Value::ObjectPath(v) => MetadataValue::TrackID(TrackID::from(v)), Value::Array(a) if a.full_signature() == "as" => { let mut strings = Vec::with_capacity(a.len()); - for v in a.into_iter() { + for v in a.iter() { if let Value::Str(s) = v { strings.push(s.to_string()); } @@ -104,35 +134,306 @@ impl<'a> From> for MetadataValue { } } -#[test] -fn test_signed_integer_casting() { - assert_eq!(MetadataValue::SignedInt(42).into_i64(), Some(42)); - assert_eq!(MetadataValue::SignedInt(-42).into_i64(), Some(-42)); - assert_eq!(MetadataValue::UnsignedInt(42).into_i64(), Some(42)); - assert_eq!(MetadataValue::Boolean(true).into_i64(), None); +impl From for MetadataValue { + fn from(value: bool) -> Self { + Self::Boolean(value) + } +} + +impl From for MetadataValue { + fn from(value: f64) -> Self { + Self::Float(value) + } +} + +impl From for MetadataValue { + fn from(value: i64) -> Self { + Self::SignedInt(value) + } +} + +impl From for MetadataValue { + fn from(value: u64) -> Self { + Self::UnsignedInt(value) + } +} + +impl From for MetadataValue { + fn from(value: String) -> Self { + Self::String(value) + } +} + +impl From> for MetadataValue { + fn from(value: Vec) -> Self { + Self::Strings(value) + } +} + +impl From for MetadataValue { + fn from(value: TrackID) -> Self { + Self::TrackID(value) + } +} + +impl From for MetadataValue { + fn from(value: crate::MprisDuration) -> Self { + Self::SignedInt(value.into()) + } +} + +impl TryFrom for bool { + type Error = InvalidMetadataValue; + + fn try_from(value: MetadataValue) -> Result { + match value { + MetadataValue::Boolean(v) => Ok(v), + _ => Err(InvalidMetadataValue::expected("MetadataValue::Boolean")), + } + } +} + +impl TryFrom for f64 { + type Error = InvalidMetadataValue; + + fn try_from(value: MetadataValue) -> Result { + match value { + MetadataValue::Float(v) => Ok(v), + _ => Err(InvalidMetadataValue::expected("MetadataValue::Float")), + } + } +} + +impl TryFrom for i64 { + type Error = InvalidMetadataValue; + + fn try_from(value: MetadataValue) -> Result { + match value { + MetadataValue::SignedInt(v) => Ok(v), + MetadataValue::UnsignedInt(v) => { + if v <= i64::MAX as u64 { + Ok(v as i64) + } else { + Err(InvalidMetadataValue::from("value too big for i64")) + } + } + _ => Err(InvalidMetadataValue::expected( + "MetadataValue::SignedInt or MetadataValue::UnsignedInt", + )), + } + } +} + +impl TryFrom for u64 { + type Error = InvalidMetadataValue; + + fn try_from(value: MetadataValue) -> Result { + match value { + MetadataValue::UnsignedInt(v) => Ok(v), + MetadataValue::SignedInt(v) => { + if v >= 0 { + Ok(v as u64) + } else { + Err(InvalidMetadataValue::from("value is negative")) + } + } + _ => Err(InvalidMetadataValue::expected( + "MetadataValue::SignedInt or MetadataValue::UnsignedInt", + )), + } + } +} + +impl TryFrom for String { + type Error = InvalidMetadataValue; + + fn try_from(value: MetadataValue) -> Result { + match value { + MetadataValue::String(v) => Ok(v), + MetadataValue::Strings(mut v) => { + if v.len() == 1 { + Ok(v.pop().expect("length was checked to be 1")) + } else { + Err(InvalidMetadataValue::from( + "MetadataValue::Strings contains more than 1 String", + )) + } + } + _ => Err(InvalidMetadataValue::expected( + "MetadataValue::Strings or MetadataValue::String", + )), + } + } +} + +impl TryFrom for Vec { + type Error = InvalidMetadataValue; - assert_eq!( - MetadataValue::UnsignedInt(u64::MAX).into_i64(), - Some(i64::MAX) - ); + fn try_from(value: MetadataValue) -> Result { + match value { + MetadataValue::String(v) => Ok(vec![v]), + MetadataValue::Strings(v) => Ok(v), + _ => Err(InvalidMetadataValue::expected( + "MetadataValue::Strings or MetadataValue::String", + )), + } + } } -#[test] -fn test_unsigned_integer_casting() { - assert_eq!(MetadataValue::SignedInt(42).into_u64(), Some(42)); - assert_eq!(MetadataValue::SignedInt(-42).into_u64(), Some(0)); - assert_eq!(MetadataValue::UnsignedInt(42).into_u64(), Some(42)); - assert_eq!(MetadataValue::Boolean(true).into_u64(), None); +#[cfg(test)] +mod metadata_value_integer_tests { + use super::*; + + #[test] + fn test_signed_integer_casting() { + assert_eq!( + MetadataValue::SignedInt(i64::MIN).into_i64(), + Some(i64::MIN) + ); + assert_eq!(MetadataValue::SignedInt(0).into_i64(), Some(0_i64)); + assert_eq!( + MetadataValue::SignedInt(i64::MAX).into_i64(), + Some(i64::MAX) + ); + assert_eq!(MetadataValue::UnsignedInt(0).into_i64(), Some(0_i64)); + assert_eq!( + MetadataValue::UnsignedInt(u64::MAX).into_i64(), + Some(i64::MAX) + ); - assert_eq!( - MetadataValue::SignedInt(i64::MAX).into_u64(), - Some(i64::MAX as u64) - ); + assert_eq!(MetadataValue::SignedInt(i64::MIN).try_into(), Ok(i64::MIN)); + assert_eq!(MetadataValue::SignedInt(0_i64).try_into(), Ok(0_i64)); + assert_eq!(MetadataValue::SignedInt(i64::MAX).try_into(), Ok(i64::MAX)); + assert_eq!(MetadataValue::UnsignedInt(0).try_into(), Ok(0_i64)); + assert!(i64::try_from(MetadataValue::UnsignedInt(u64::MAX)).is_err()); + } - assert_eq!(MetadataValue::SignedInt(i64::MIN).into_u64(), Some(0)); + #[test] + fn test_unsigned_integer_casting() { + assert_eq!(MetadataValue::SignedInt(i64::MIN).into_u64(), Some(0_u64)); + assert_eq!(MetadataValue::SignedInt(0).into_u64(), Some(0_u64)); + assert_eq!( + MetadataValue::SignedInt(i64::MAX).into_u64(), + Some(i64::MAX as u64) + ); + assert_eq!(MetadataValue::UnsignedInt(0).into_u64(), Some(0_u64)); + assert_eq!( + MetadataValue::UnsignedInt(u64::MAX).into_u64(), + Some(u64::MAX) + ); - assert_eq!( - MetadataValue::UnsignedInt(u64::MAX).into_u64(), - Some(u64::MAX) - ); + assert!(u64::try_from(MetadataValue::SignedInt(i64::MIN)).is_err()); + assert_eq!(MetadataValue::SignedInt(0).try_into(), Ok(0_u64)); + assert_eq!( + MetadataValue::SignedInt(i64::MAX).try_into(), + Ok(i64::MAX as u64) + ); + assert_eq!(MetadataValue::UnsignedInt(0).try_into(), Ok(0_u64)); + assert_eq!( + MetadataValue::UnsignedInt(u64::MAX).try_into(), + Ok(u64::MAX) + ); + } +} + +#[cfg(test)] +mod metadata_value_serde { + use super::*; + use serde_test::{assert_de_tokens, assert_ser_tokens, Token}; + + #[test] + fn serialization() { + assert_ser_tokens(&MetadataValue::Boolean(true), &[Token::Bool(true)]); + assert_ser_tokens(&MetadataValue::UnsignedInt(1), &[Token::U64(1)]); + assert_ser_tokens(&MetadataValue::UnsignedInt(0), &[Token::U64(0)]); + assert_ser_tokens(&MetadataValue::SignedInt(-1), &[Token::I64(-1)]); + assert_ser_tokens(&MetadataValue::SignedInt(0), &[Token::I64(0)]); + assert_ser_tokens(&MetadataValue::Float(0.0), &[Token::F64(0.0)]); + assert_ser_tokens( + &MetadataValue::TrackID(TrackID::try_from("/valid/path").unwrap()), + &[Token::Str("/valid/path")], + ); + assert_ser_tokens( + &MetadataValue::String(String::from("test")), + &[Token::String("test")], + ); + assert_ser_tokens( + &MetadataValue::Strings(vec![String::from("one"), String::from("two")]), + &[ + Token::Seq { len: Some(2) }, + Token::String("one"), + Token::String("two"), + Token::SeqEnd, + ], + ); + assert_ser_tokens(&MetadataValue::Unsupported, &[Token::Unit]); + } + + #[test] + fn deserialization() { + assert_de_tokens(&MetadataValue::Boolean(true), &[Token::Bool(true)]); + + assert_de_tokens( + &MetadataValue::UnsignedInt(u64::MAX), + &[Token::U64(u64::MAX)], + ); + + let signed = MetadataValue::SignedInt(0); + let neg = MetadataValue::SignedInt(-1); + assert_de_tokens(&signed, &[Token::I64(0)]); + assert_de_tokens(&signed, &[Token::U64(0)]); + assert_de_tokens(&signed, &[Token::U8(0)]); + assert_de_tokens(&signed, &[Token::I8(0)]); + assert_de_tokens(&neg, &[Token::I64(-1)]); + assert_de_tokens(&neg, &[Token::I8(-1)]); + + let float = MetadataValue::Float(0.0); + assert_de_tokens(&float, &[Token::F32(0.0)]); + assert_de_tokens(&float, &[Token::F64(0.0)]); + + assert_de_tokens( + &MetadataValue::String(String::from("/valid/path")), + &[Token::Str("/valid/path")], + ); + let string = MetadataValue::String(String::from("test")); + assert_de_tokens(&string, &[Token::String("test")]); + assert_de_tokens(&string, &[Token::BorrowedStr("test")]); + + let strings = MetadataValue::Strings(vec![String::from("first"), String::from("second")]); + assert_de_tokens( + &strings, + &[ + Token::Seq { len: Some(2) }, + Token::String("first"), + Token::String("second"), + Token::SeqEnd, + ], + ); + assert_de_tokens( + &strings, + &[ + Token::Seq { len: Some(2) }, + Token::BorrowedStr("first"), + Token::BorrowedStr("second"), + Token::SeqEnd, + ], + ); + + let unsupported = MetadataValue::Unsupported; + assert_de_tokens(&unsupported, &[Token::Unit]); + assert_de_tokens(&unsupported, &[Token::Char('a')]); + assert_de_tokens(&unsupported, &[Token::None]); + assert_de_tokens(&unsupported, &[Token::Map { len: None }, Token::MapEnd]); + assert_de_tokens( + &unsupported, + &[ + Token::StructVariant { + name: "test", + variant: "test", + len: 0, + }, + Token::StructVariantEnd, + ], + ); + } } diff --git a/src/player.rs b/src/player.rs index 3357ae0..37aaf60 100644 --- a/src/player.rs +++ b/src/player.rs @@ -1,94 +1,1308 @@ -use std::collections::HashMap; - +use futures_util::{join, try_join}; use zbus::{names::BusName, Connection}; use crate::{ - metadata::MetadataValue, - proxies::{DBusProxy, MediaPlayer2Proxy, PlayerProxy}, - Metadata, Mpris, + metadata::RawMetadata, + playlist::PlaylistsInterface, + proxies::{DBusProxy, MediaPlayer2Proxy, PlayerProxy, TrackListProxy}, + LoopStatus, Metadata, Mpris, MprisDuration, MprisError, PlaybackStatus, Playlist, + PlaylistOrdering, TrackID, MPRIS2_PREFIX, }; -pub(crate) const MPRIS2_PREFIX: &str = "org.mpris.MediaPlayer2."; -// pub(crate) const MPRIS2_PATH: &str = "/org/mpris/MediaPlayer2"; - -pub struct Player<'conn> { - mp2_proxy: MediaPlayer2Proxy<'conn>, - player_proxy: PlayerProxy<'conn>, +/// Struct that represents a player connected to the D-Bus server. Can be used to query and control +/// a player. +/// +/// The easiest way to create a [`Player`] is to use one of [`Mpris`][crate::Mpris]'s find methods, +/// see the [crate documentation][crate#getting-started] for examples. +/// +/// # Bus Name +/// +/// ## Well-know +/// +/// The [`Player`] created through [`Mpris`][crate::Mpris] is bound to it's "well-known" Bus Name +/// and not it's "unique" Bus Name (see [here][bus] for details) which means that an instance of +/// [`Player`] can still be used even after the player restarts. +/// ```no_run +/// use mpris::Mpris; +/// +/// #[async_std::main] +/// async fn main() { +/// let mpris = Mpris::new().await.unwrap(); +/// let vlc = mpris +/// .find_by_name("VLC media player", false) +/// .await +/// .unwrap() +/// .unwrap(); +/// // VLC restarts here +/// vlc.play().await.expect("will not panic"); +/// } +/// ``` +/// +/// ## Unique +/// +/// If you want to create a [`Player`] that's bound to a unique name you can use +/// [`new()`][Player::new] directly. In that case the instance of [`Player`] will stop working if +/// the player disconnects from the D-Bus for any reason. +/// ```no_run +/// use mpris::{Mpris, Player}; +/// use zbus::names::BusName; +/// #[async_std::main] +/// async fn main() { +/// let mpris = Mpris::new().await.unwrap(); +/// // A "unique" Bus Name +/// let unique_name = BusName::try_from(":1.123").unwrap(); +/// let vlc = Player::new(&mpris, unique_name) +/// .await +/// .unwrap(); +/// +/// // VLC restarts here +/// vlc.play().await.expect("will panic") +/// } +/// ``` +/// +/// # Interfaces +/// +/// The [MPRIS specification][spec] contains 4 interfaces: +/// +/// ## [org.mpris.MediaPlayer2][mp2] +/// +///
Index of methods for this interface +/// +/// ### Methods +/// - `Raise`: [`raise()`][Self::raise] +/// - `Quit`: [`quit()`][Self::quit] +/// +/// ### Properties +/// - `CanQuit`: [`can_quit()`][Self::can_quit] +/// - `CanRaise`: [`can_raise()`][Self::can_raise] +/// - `DesktopEntry`: [`desktop_entry()`][Self::desktop_entry] +/// - `Fullscreen`: [`get_fullscreen()`][Self::get_fullscreen] / +/// [`set_fullscreen()`][Self::set_fullscreen] +/// - `CanSetFullscreen`: [`can_set_fullscreen()`][Self::can_set_fullscreen] +/// - `HasTrackList`: [`has_track_list()`][Self::has_track_list] +/// - `Identify`: [`identity()`][Self::identity] +/// - `SupportedMimeTypes`: [`supported_mime_types()`][Self::supported_mime_types] +/// - `SupportedUriSchemes`: [`supported_uri_schemes()`][Self::supported_uri_schemes] +/// +///
+/// +/// ## [org.mpris.MediaPlayer2.Player][player] +/// +/// > This interface implements the methods for querying and providing basic control over what is +/// > currently playing. +/// +///
Index of methods for this interface +/// +/// ### Methods +/// - `Next`: [`next()`][Self::next] +/// - `Previous`: [`previous()`][Self::previous] +/// - `Play`: [`play()`][Self::play] +/// - `Pause`: [`pause()`][Self::pause] +/// - `PlayPause`: [`play_pause()`][Self::play_pause] +/// - `Stop`: [`stop()`][Self::stop] +/// - `Seek`: [`seek()`][Self::seek] / [`seek_forwards()`][Self::seek_forwards] / +/// [`seek_backwards()`][Self::seek_backwards] +/// - `SetPosition`: [`set_position()`][Self::set_position] +/// - `OpenUri`: [`open_uri()`][Self::open_uri] +/// +/// ### Properties +/// - `CanControl`: [`can_control()`][Self::can_control] +/// - `CanGoNext`: [`can_go_next()`][Self::can_go_next] +/// - `CanGoPrevious`: [`can_go_previous()`][Self::can_go_previous] +/// - `CanPlay`: [`can_play()`][Self::can_play] +/// - `CanPause`: [`can_pause()`][Self::can_pause] +/// - `CanSeek`: [`can_seek()`][Self::can_seek] +/// - `LoopStatus`: [`get_loop_status()`][Self::get_loop_status] / +/// [`set_loop_status()`][Self::set_loop_status] +/// - `MaximumRate`: [`maximum_rate()`][Self::maximum_rate] +/// - `MinimumRate`: [`minimum_rate()`][Self::minimum_rate] +/// - `Metadata`: [`metadata()`][Self::metadata] / [`raw_metadata()`][Self::raw_metadata] +/// - `PlaybackStatus`: [`playback_status()`][Self::playback_status] +/// - `Position`: [`get_position()`][Self::get_position] +/// - `Rate`: [`get_playback_rate()`][Self::get_playback_rate] / +/// [`set_playback_rate()`][Self::set_playback_rate] +/// - `Shuffle`: [`get_shuffle()`][Self::get_shuffle] / [`set_shuffle()`][Self::set_shuffle] +/// - `Volume`: [`get_volume()`][Self::get_volume] / [`set_volume()`][Self::set_volume] +/// +///
+/// +/// ## [org.mpris.MediaPlayer2.TrackList][tracklist] +/// +/// **This is an optional interface.** +/// +/// > Provides access to a short list of tracks which were recently played or will be played +/// > shortly. This is intended to provide context to the currently-playing track, rather than +/// > giving complete access to the media player's playlist. +/// > +/// > Example use cases are the list of tracks from the same album as the currently playing song or +/// > the Rhythmbox play queue. +/// > +/// > Each track in the tracklist has a unique identifier. The intention is that this uniquely +/// > identifies the track within the scope of the tracklist. In particular, if a media item (a +/// > particular music file, say) occurs twice in the track list, each occurrence should have a +/// > different identifier. If a track is removed from the middle of the playlist, it should not +/// > affect the track ids of any other tracks in the tracklist. +/// > +/// > As a result, the traditional track identifiers of URLs and position in the playlist cannot be +/// > used. Any scheme which satisfies the uniqueness requirements is valid, as clients should not +/// > make any assumptions about the value of the track id beyond the fact that it is a unique +/// > identifier. +/// > +/// > Note that the (memory and processing) burden of implementing the TrackList interface and +/// > maintaining unique track ids for the playlist can be mitigated by only exposing a subset of +/// > the playlist when it is very long (the 20 or so tracks around the currently playing track, for +/// > example). This is a recommended practice as the tracklist interface is not designed to enable +/// > browsing through a large list of tracks, but rather to provide clients with context about the +/// > currently playing track. +/// +///
Index of methods for this interface +/// +/// ### Methods +/// - `AddTrack`: [`add_track()`][Self::add_track] +/// - `GetTracksMetadata`: [`get_tracks_metadata()`][Self::get_tracks_metadata] +/// - `GoTo`: [`go_to()`][Self::go_to] +/// - `RemoveTrack`: [`remove_track()`][Self::remove_track] +/// +/// ### Properties +/// - `CanEditTracks`: [`can_edit_tracks()`][Self::can_edit_tracks] +/// - `Tracks`: [`tracks()`][Self::tracks] +/// +///
+/// +/// ## [org.mpris.MediaPlayer2.Playlists][playlists] +/// +/// **This is an optional interface.** +/// +/// > Provides access to the media player's playlists. +/// +///
Index of methods for this interface +/// +/// ### Methods +/// - `ActivatePlaylist`: [`activate_playlist()`][Self::activate_playlist] +/// - `GetPlaylists`: [`get_playlists()`][Self::get_playlists] +/// +/// ### Properties +/// - `ActivePlaylist`: [`active_playlist()`][Self::active_playlist] +/// - `Orderings`: [`orderings()`][Self::orderings] +/// - `Orderings`: [`playlist_count()`][Self::playlist_count] +/// +///
+/// +/// ## Support checking +/// +/// Interfaces are checked by calling one of their methods only when [`Player`] gets created. This +/// means that if for some reason the player implements an interface at a later point the created +/// [`Player`] will not know about it. +/// +/// Support for the 2 optional interfaces can be checked with: +/// - [`supports_track_list_interface()`][Self::supports_track_list_interface] +/// - [`supports_playlists_interface()`][Self::supports_playlists_interface] +/// +///
Note that just because an interface is implemented it doesn't mean that the +/// player implemented it correctly, even in the case of the required ones. Methods might be missing +/// or it might return wrong data. This library is not trying to correct those mistakes and will +/// simply return an error.
+/// +/// [spec]: https://specifications.freedesktop.org/mpris-spec/latest +/// [mp2]: https://specifications.freedesktop.org/mpris-spec/latest/Media_Player.html +/// [player]: https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html +/// [tracklist]: https://specifications.freedesktop.org/mpris-spec/latest/Track_List_Interface.html +/// [playlists]: https://specifications.freedesktop.org/mpris-spec/latest/Playlists_Interface.html +/// [bus]: https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-names-bus +#[derive(Clone)] +pub struct Player { + bus_name: BusName<'static>, + dbus_proxy: DBusProxy<'static>, + mp2_proxy: MediaPlayer2Proxy<'static>, + player_proxy: PlayerProxy<'static>, + track_list_proxy: Option>, + playlist_interface: Option, } -impl<'conn> Player<'conn> { - pub async fn new( - mpris: &'conn Mpris, - bus_name: BusName<'static>, - ) -> Result, Box> { - Player::new_from_connection(mpris.connection.clone(), bus_name).await +impl Player { + /// Creates a new [`Player`] for the given [`Connection`] and [`BusName`]. + /// + /// In most cases there is no need to create [`Player`]s directly, instead you should create + /// them through [`Mpris`][crate::Mpris]. Doing it this way however allows you to bind the + /// [`Player`] to a unique Bus Name. See [this][Self#bus-name] for a simple explanation. + pub async fn new(mpris: &Mpris, bus_name: BusName<'static>) -> Result { + Self::new_internal( + mpris.get_connection(), + mpris.get_dbus_proxy().await?.clone(), + bus_name, + ) + .await } - pub(crate) async fn new_from_connection( - connection: Connection, + pub(crate) async fn new_internal( + conn: Connection, + dbus_proxy: DBusProxy<'static>, bus_name: BusName<'static>, - ) -> Result, Box> { - let mp2_proxy = MediaPlayer2Proxy::builder(&connection) - .destination(bus_name.clone())? - .build() - .await?; + ) -> Result { + let (mp2_proxy, player_proxy, track_list_proxy, playlists_interface) = try_join!( + MediaPlayer2Proxy::new(&conn, bus_name.clone()), + PlayerProxy::new(&conn, bus_name.clone()), + TrackListProxy::new(&conn, bus_name.clone()), + PlaylistsInterface::new(&conn, bus_name.clone()), + )?; - let player_proxy = PlayerProxy::builder(&connection) - .destination(bus_name.clone())? - .build() - .await?; + let (track_list, playlists) = join!( + track_list_proxy.can_edit_tracks(), + playlists_interface.playlist_count(), + ); Ok(Player { + bus_name, + dbus_proxy, mp2_proxy, player_proxy, + track_list_proxy: if track_list.is_ok() { + Some(track_list_proxy) + } else { + None + }, + playlist_interface: if playlists.is_ok() { + Some(playlists_interface) + } else { + None + }, }) } - pub async fn identity(&self) -> Result> { - Ok(self.mp2_proxy.identity().await?) + /// Returns the player's [`HasTrackList`][track_list] property. + /// + /// **Note**: this property is reported by the player and might not be accurate. A better way to + /// check is [`supports_track_list_interface()`][Self::supports_track_list_interface] + /// + /// [track_list]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Media_Player.html#Property:HasTrackList + pub async fn has_track_list(&self) -> Result { + self.mp2_proxy.has_track_list().await } - pub async fn metadata(&self) -> Result> { - Ok(self.raw_metadata().await?.into()) + /// Checks if the [`Player`] has support for the + /// [`TrackList`][Self#orgmprismediaplayer2tracklist] interface. + /// + /// See the [interfaces section for more details][Self#interfaces] + pub fn supports_track_list_interface(&self) -> bool { + self.track_list_proxy.is_some() } - pub async fn raw_metadata( - &self, - ) -> Result, Box> { - let data = self.player_proxy.metadata().await?; - let raw: HashMap = - data.into_iter().map(|(k, v)| (k, v.into())).collect(); - Ok(raw) + /// Checks if the [`Player`] has support for the + /// [`Playlists`][Self#orgmprismediaplayer2playlists] interface. + /// + /// See the [interfaces section for more details][Self#interfaces] + pub fn supports_playlists_interface(&self) -> bool { + self.playlist_interface.is_some() } - pub fn bus_name(&self) -> &str { - self.mp2_proxy.bus_name() + /// Queries the player for current metadata. + /// + /// See [`Metadata`] for more information. + pub async fn metadata(&self) -> Result { + Ok(self.raw_metadata().await?.try_into()?) } -} -impl<'a> std::fmt::Debug for Player<'a> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Player") - .field("bus_name", &self.bus_name()) - .finish() + /// Queries the player for current metadata and returns it as is. + /// + /// Similar to [`metadata()`][Self::metadata] but doesn't perform any checks or conversions. See + /// [`Metadata::from_raw_lossy()`] for details. + pub async fn raw_metadata(&self) -> Result { + self.player_proxy.metadata().await + } + + /// Checks if the player is still connected. + /// + /// Simply pings the player and checks if it responds. + /// + /// This could return [`Err`] even if the player is connected but something with the connection + /// went wrong. + pub async fn is_running(&self) -> Result { + match self.mp2_proxy.ping().await { + Ok(_) => Ok(true), + Err(e) => match e { + MprisError::DbusError(zbus::Error::MethodError(e_name, _, _)) + if e_name == "org.freedesktop.DBus.Error.ServiceUnknown" => + { + Ok(false) + } + _ => Err(e), + }, + } } -} -pub(crate) async fn all( - connection: &Connection, -) -> Result, Box> { - let connection = connection.clone(); - let proxy = DBusProxy::new(&connection).await?; - let names = proxy.list_names().await?; - - let mut players = Vec::new(); - for name in names.into_iter() { - if name.starts_with(MPRIS2_PREFIX) { - if let Ok(bus_name) = name.try_into() { - players.push(Player::new_from_connection(connection.clone(), bus_name).await?); + /// Returns the Bus Name of the [`Player`]. + /// + /// See also: [`bus_name_trimmed()`][Self::bus_name_trimmed] and + /// [`unique_bus_name()`][Self::unique_bus_name]. + pub fn bus_name(&self) -> &str { + self.bus_name.as_str() + } + + /// Returns the Unique Bus Name of the [`Player`]. + /// + /// If it returns [`None`] then no player is currently connected. + /// + /// If you just want to check if the player is connected you should use + /// [`is_running()`][Self::is_running] instead. + /// + /// See also: [`bus_name()`][Self::bus_name]. + pub async fn unique_bus_name(&self) -> Result, MprisError> { + // If Player is bound to a unique name there's no need to check. + if let BusName::Unique(unique_name) = &self.bus_name { + Ok(Some(unique_name.to_string())) + } else { + match self.dbus_proxy.get_name_owner(&self.bus_name).await { + Ok(name) => Ok(Some(name.to_string())), + Err(e) => match e { + MprisError::DbusError(zbus::Error::MethodError(e_name, _, _)) + if e_name == "org.freedesktop.DBus.Error.NameHasNoOwner" => + { + Ok(None) + } + _ => Err(e), + }, } } } - Ok(players) + /// Returns the player name part of the player's D-Bus bus name with the MPRIS2 prefix trimmed. + /// + /// Examples: + /// - `org.mpris.MediaPlayer2.io.github.celluloid_player.Celluloid` -> + /// `io.github.celluloid_player.Celluloid` + /// - `org.mpris.MediaPlayer2.Spotify.` -> `Spotify` + /// - `org.mpris.MediaPlayer2.mpv.instance123` -> `mpv.instance123` + /// + /// See also: [`bus_name()`][Self::bus_name]. + pub fn bus_name_trimmed(&self) -> &str { + self.bus_name().trim_start_matches(MPRIS2_PREFIX) + } + + /// Sends a `Quit` signal to the player. + /// + /// > Causes the media player to stop running. + /// > + /// > The media player may refuse to allow clients to shut it down. In this case, the CanQuit + /// > property is false and this method does nothing. + /// + /// See also: [MPRIS2 specification about `Quit`][quit] and [`can_quit()`][Self::can_quit]. + /// + /// [quit]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Media_Player.html#Method:Quit + pub async fn quit(&self) -> Result<(), MprisError> { + self.mp2_proxy.quit().await + } + + /// Queries the player to see if it can be asked to quit. + /// + /// > If false, calling Quit will have no effect, and may raise a NotSupported error. If true, + /// > calling Quit will cause the media application to attempt to quit (although it may still + /// > be prevented from quitting by the user, for example). + /// + /// See also: [MPRIS2 specification about `CanQuit`][can_quit] and [`quit()`][Self::quit]. + /// + /// [can_quit]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Media_Player.html#Property:CanQuit + pub async fn can_quit(&self) -> Result { + self.mp2_proxy.can_quit().await + } + + /// Send a `Raise` signal to the player. + /// + /// > Brings the media player's user interface to the front using any appropriate mechanism + /// > available. + /// > + /// > The media player may be unable to control how its user interface is displayed, or it may + /// > not have a graphical user interface at all. In this case, the CanRaise property is false + /// > and this method does nothing. + /// + /// See also: [MPRIS2 specification about `Raise`][raise] and [`can_raise()`][Self::can_raise]. + /// + /// [raise]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Media_Player.html#Method:Raise + pub async fn raise(&self) -> Result<(), MprisError> { + self.mp2_proxy.raise().await + } + + /// Queries the player to see if it can be raised or not. + /// + /// > If false, calling Raise will have no effect, and may raise a NotSupported error. If true, + /// > calling Raise will cause the media application to attempt to bring its user interface to + /// > the front, although it may be prevented from doing so (by the window manager, for + /// > example). + /// + /// See also: [MPRIS2 specification about `CanRaise`][can_raise] and [`raise()`][Self::raise] + /// + /// [can_raise]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Media_Player.html#Property:CanRaise + pub async fn can_raise(&self) -> Result { + self.mp2_proxy.can_raise().await + } + /// Returns the player's [`DesktopEntry`][entry] property, if supported. + /// + /// > The basename of an installed .desktop file which complies with the Desktop entry + /// > specification, with the ".desktop" extension stripped. + /// > + /// > Example: The desktop entry file is "/usr/share/applications/vlc.desktop", and this + /// > property contains "vlc" + /// + /// [entry]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Media_Player.html#Property:DesktopEntry + pub async fn desktop_entry(&self) -> Result { + self.mp2_proxy.desktop_entry().await + } + + /// Returns the player's MPRIS [`Identity`][identity]. + /// + /// > A friendly name to identify the media player to users. + /// > + /// > This should usually match the name found in .desktop files + /// > + /// > (eg: "VLC media player"). + /// + /// [identity]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Media_Player.html#Property:Identity + pub async fn identity(&self) -> Result { + self.mp2_proxy.identity().await + } + + /// Returns the player's [`SupportedMimeTypes`][mime] property. + /// + /// > The mime-types supported by the media player. + /// > + /// > Mime-types should be in the standard format (eg: audio/mpeg or application/ogg). + /// + /// [mime]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Media_Player.html#Property:SupportedMimeTypes + pub async fn supported_mime_types(&self) -> Result, MprisError> { + self.mp2_proxy.supported_mime_types().await + } + + /// Returns the player's [`SupportedUriSchemes`][uri] property. + /// + /// > The URI schemes supported by the media player. + /// > + /// > This can be viewed as protocols supported by the player in almost all cases. Almost every + /// > media player will include support for the "file" scheme. Other common schemes are "http" + /// > and "rtsp". + /// > + /// > Note that URI schemes should be lower-case. + /// + /// [uri]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Media_Player.html#Property:SupportedUriSchemes + pub async fn supported_uri_schemes(&self) -> Result, MprisError> { + self.mp2_proxy.supported_uri_schemes().await + } + + /// Returns the player's [`Fullscreen`][full] property. + /// + ///
This property was added in MPRIS 2.2, and not all players will + /// implement it.
+ /// + /// > Whether the media player is occupying the fullscreen. + /// > + /// > This is typically used for videos. A value of true indicates that the media player is + /// > taking up the full screen. + /// > + /// > Media centre software may well have this value fixed to true + /// + /// See also: [`set_fullscreen()`][Self::set_fullscreen] and + /// [`can_set_fullscreen()`][Self::can_set_fullscreen]. + /// + /// [full]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Media_Player.html#Property:Fullscreen + pub async fn get_fullscreen(&self) -> Result { + self.mp2_proxy.fullscreen().await + } + + /// Asks the player to set the [`Fullscreen`][full] property. + /// + ///
This property was added in MPRIS 2.2, and not all players will + /// implement it.
+ /// + /// See [`get_fullscreen`][Self::get_fullscreen()] and + /// [`can_set_fullscreen()`][Self::can_set_fullscreen] for more information + /// + /// [full]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Media_Player.html#Property:Fullscreen + pub async fn set_fullscreen(&self, value: bool) -> Result<(), MprisError> { + self.mp2_proxy.set_fullscreen(value).await + } + + /// Queries the player to see if it can be asked to enter fullscreen. + /// + ///
This property was added in MPRIS 2.2, and not all players will + /// implement it.
+ /// + /// > If false, attempting to set Fullscreen will have no effect, and may raise an error. If + /// > true, attempting to set Fullscreen will not raise an error, and (if it is different from + /// > the current value) will cause the media player to attempt to enter or exit fullscreen + /// > mode. + /// > + /// > Note that the media player may be unable to fulfil the request. In this case, the value + /// > will not change. If the media player knows in advance that it will not be able to fulfil + /// > the request, however, this property should be false. + /// + /// See also: [MPRIS2 specification about `CanSetFullscreen`][can_full], + /// [`get_fullscreen()`][Self::get_fullscreen] and [`set_fullscreen()`][Self::set_fullscreen]. + /// + /// [can_full]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Media_Player.html#Property:CanSetFullscreen + pub async fn can_set_fullscreen(&self) -> Result { + self.mp2_proxy.can_set_fullscreen().await + } + + /// Queries the player to see if it can be controlled or not. + /// + /// > Whether the media player may be controlled over this interface. + /// > + /// > This property is not expected to change, as it describes an intrinsic capability of the + /// > implementation. + /// > + /// > If this is false, clients should assume that all properties on the + /// > [`org.mpris.MediaPlayer2.Player`][Self#orgmprismediaplayer2player] interface are read-only + /// > (and will raise errors if writing to them is attempted), no methods are implemented and + /// > all other properties starting with "Can" are also false. + /// + /// See also: [MPRIS2 specification about `CanControl`][control]. + /// + /// [control]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Property:CanControl + pub async fn can_control(&self) -> Result { + self.player_proxy.can_control().await + } + + /// Sends a [`Next`][next] signal to the player. + /// + /// > Skips to the next track in the tracklist. + /// > + /// > If there is no next track (and endless playback and track repeat are both off), stop + /// > playback. + /// > + /// > If playback is paused or stopped, it remains that way. + /// > + /// > If CanGoNext is false, attempting to call this method should have no effect. + /// + /// See also: [`can_go_next()`][Self::can_go_next]. + /// + /// [next]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Method:Next + pub async fn next(&self) -> Result<(), MprisError> { + self.player_proxy.next().await + } + + /// Queries the player to see if it can go to next. + /// + /// > Whether the client can call the Next method on this interface and expect the current track + /// > to change. + /// > + /// > If it is unknown whether a call to Next will be successful (for example, when streaming + /// > tracks), this property should be set to true. + /// > + /// > If CanControl is false, this property should also be false. + /// + /// See also: [MPRIS2 specification about `CanGoNext`][can_next] and [`next()`][Self::next]. + /// + /// [can_next]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Property:CanGoNext + pub async fn can_go_next(&self) -> Result { + self.player_proxy.can_go_next().await + } + + /// Sends a [`Previous`][prev] signal to the player. + /// + /// > Skips to the previous track in the tracklist. + /// > + /// > If there is no previous track (and endless playback and track repeat are both off), stop + /// > playback. + /// > + /// > If playback is paused or stopped, it remains that way. + /// > + /// > If CanGoPrevious is false, attempting to call this method should have no effect. + /// + /// See also: [`can_go_previous()`][Self::can_go_previous]. + /// + /// [prev]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Method:Previous + pub async fn previous(&self) -> Result<(), MprisError> { + self.player_proxy.previous().await + } + + /// Queries the player to see if it can go to previous or not. + /// + /// > Whether the client can call the Previous method on this interface and expect the current + /// > track to change. + /// > + /// > If it is unknown whether a call to Previous will be successful (for example, when + /// > streaming tracks), this property should be set to true. + /// > + /// > If CanControl is false, this property should also be false. + /// + /// See also: [MPRIS2 specification about `CanGoPrevious`][can_prev] and [`previous()`][Self::previous]. + /// + /// [can_prev]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Property:CanGoPrevious + pub async fn can_go_previous(&self) -> Result { + self.player_proxy.can_go_previous().await + } + + /// Sends a [`Play`][play] signal to the player. + /// + /// > Starts or resumes playback. + /// > + /// > If already playing, this has no effect. + /// > + /// > If paused, playback resumes from the current position. + /// > + /// > If there is no track to play, this has no effect. + /// > + /// > If CanPlay is false, attempting to call this method should have no effect. + /// + /// See also: [`can_play()`][Self::can_play]. + /// + /// [play]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Method:Play + pub async fn play(&self) -> Result<(), MprisError> { + self.player_proxy.play().await + } + + /// Queries the player to see if it can play. + /// + /// > Whether playback can be started using Play or PlayPause. + /// > + /// > Note that this is related to whether there is a "current track": the value should not depend on whether the track is currently paused or playing. In fact, if a track is currently playing (and CanControl is true), this should be true. + /// > + /// > If CanControl is false, this property should also be false. + /// + /// See also: [MPRIS2 specification about `CanPlay`][can_play] and [`play()`][Self::play]. + /// + /// [can_play]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Property:CanPlay + pub async fn can_play(&self) -> Result { + self.player_proxy.can_play().await + } + + /// Sends a [`Pause`][pause] signal to the player. + /// + /// > Pauses playback. + /// > + /// > If playback is already paused, this has no effect. + /// > + /// > Calling Play after this should cause playback to start again from the same position. + /// > + /// > If CanPause is false, attempting to call this method should have no effect. + /// + /// See also: [`can_pause()`][Self::can_pause]. + /// + /// [pause]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Method:Pause + pub async fn pause(&self) -> Result<(), MprisError> { + self.player_proxy.pause().await + } + + /// Queries the player to see if it can pause. + /// + /// > Whether playback can be paused using Pause or PlayPause. + /// > + /// > Note that this is an intrinsic property of the current track: its value should not depend + /// > on whether the track is currently paused or playing. In fact, if playback is currently + /// > paused (and CanControl is true), this should be true. + /// > + /// > If CanControl is false, this property should also be false. + /// + /// See also: [MPRIS2 specification about `CanPause`][can_pause] and [`pause()`][Self::pause]. + /// + /// [can_pause]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Property:CanPause + pub async fn can_pause(&self) -> Result { + self.player_proxy.can_pause().await + } + + /// Sends a [`PlayPause`][play_pause] signal to the player. + /// + /// > Pauses playback. + /// > + /// > If playback is already paused, resumes playback. + /// > + /// > If playback is stopped, starts playback. + /// > + /// > If CanPause is false, attempting to call this method should have no effect and raise an + /// > error. + /// + /// See also: [`can_pause()`][Self::can_pause]. + /// + /// [play_pause]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Method:PlayPause + pub async fn play_pause(&self) -> Result<(), MprisError> { + self.player_proxy.play_pause().await + } + + /// Sends a [`Stop`][stop] signal to the player. + /// + /// > Stops playback. + /// > + /// > If playback is already stopped, this has no effect. + /// > + /// > Calling Play after this should cause playback to start again from the beginning of the + /// > track. + /// > + /// > If CanControl is false, attempting to call this method should have no effect and raise an + /// > error. + /// + /// [stop]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Method:Stop + pub async fn stop(&self) -> Result<(), MprisError> { + self.player_proxy.stop().await + } + + /// Sends a [`Seek`][seek] signal to the player. + /// + /// > Seeks forward in the current track by the specified number of microseconds. + /// > + /// > A negative value seeks back. If this would mean seeking back further than the start of the + /// > track, the position is set to 0. + /// > + /// > If the value passed in would mean seeking beyond the end of the track, acts like a call to + /// > Next. + /// > + /// > If the CanSeek property is false, this has no effect. + /// + /// See also: [`can_seek()`][Self::can_seek], [`seek_forwards()`][Self::seek_forwards] and + /// [`seek_backwards()`][Self::seek_backwards]. + /// + /// [seek]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Method:Seek + pub async fn seek(&self, offset_in_microseconds: i64) -> Result<(), MprisError> { + self.player_proxy.seek(offset_in_microseconds).await + } + + /// Tells the player to seek forwards. + /// + /// Similar to [`seek()`][Self::seek] but can only seek forwards and uses the more convenient + /// [`MprisDuration`] as an argument. + /// + /// See also: [`seek_backwards()`][Self::seek_backwards] + pub async fn seek_forwards(&self, offset: MprisDuration) -> Result<(), MprisError> { + self.player_proxy.seek(offset.into()).await + } + + /// Tells the player to seek backwards. + /// + /// Similar to [`seek()`][Self::seek] but can only seek backwards and uses the more convenient + /// [`MprisDuration`] as an argument. + /// + /// See also: [`seek_forwards()`][Self::seek_forwards] + pub async fn seek_backwards(&self, offset: MprisDuration) -> Result<(), MprisError> { + self.player_proxy.seek(-i64::from(offset)).await + } + + /// Queries the player to see if it can seek within the media. + /// + /// > Whether the client can control the playback position using Seek and SetPosition. This may + /// > be different for different tracks. + /// > + /// > If CanControl is false, this property should also be false. + /// + /// See also: [MPRIS2 specification about `CanSeek`][can_seek], [`seek()`][Self::seek], + /// [`seek_forwards()`][Self::seek_forwards], [`seek_backwards()`][Self::seek_backwards]. + /// + /// [can_seek]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Property:CanSeek + pub async fn can_seek(&self) -> Result { + self.player_proxy.can_seek().await + } + + /// Gets the player's MPRIS [`Position`][position] as a [`MprisDuration`] since the start of the + /// media. + /// + /// > The current track position in microseconds, between 0 and the 'mpris:length' metadata + /// > entry (see [`Metadata`][Metadata]). + /// + /// [position]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Property:Position + pub async fn get_position(&self) -> Result { + self.player_proxy.position().await + } + + /// Sets the position of the current track to the given position (as a [`MprisDuration`]). + /// + /// Current [`TrackID`] must be provided to avoid race conditions with the player, in case it + /// changes tracks while the signal is being sent. The special + /// [`"NoTrack"`][TrackID::NO_TRACK] value is not allowed. + /// + /// To obtain the current [`TrackID`] you can use [`metadata()`][Self::metadata] + ///```no_run + /// #[async_std::main] + /// async fn main() { + /// use mpris::Mpris; + /// use std::time::Duration; + /// + /// let mpris = Mpris::new().await.unwrap(); + /// let player = mpris.find_active().await.unwrap().unwrap(); + /// let track_id = player.metadata().await.unwrap().track_id.unwrap(); + /// let _ = player + /// .set_position(&track_id, Duration::from_secs(5).try_into().unwrap()) + /// .await; + /// } + ///``` + /// + /// > If the CanSeek property is false, this has no effect. + /// + /// See also: [MPRIS2 specification about `SetPosition`][set_position] and + /// [`can_seek()`][Self::can_seek]. + /// + /// [set_position]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Method:SetPosition + pub async fn set_position( + &self, + track_id: &TrackID, + position: MprisDuration, + ) -> Result<(), MprisError> { + if track_id.is_no_track() { + return Err(MprisError::track_id_is_no_track()); + } + self.player_proxy.set_position(track_id, position).await + } + + /// Gets the player's current loop status. + /// + /// See also: [MPRIS2 specification about `LoopStatus`][loop_status] and [`LoopStatus`][LoopStatus]. + /// + /// [loop_status]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Property:LoopStatus + pub async fn get_loop_status(&self) -> Result { + self.player_proxy.loop_status().await + } + + /// Sets the loop status of the player. + /// + /// > If CanControl is false, attempting to set this property should have no effect and raise an + /// > error. + /// + /// See also: [MPRIS2 specification about `LoopStatus`][loop_status] and [`LoopStatus`][LoopStatus]. + /// + /// [loop_status]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Property:LoopStatus + pub async fn set_loop_status(&self, loop_status: LoopStatus) -> Result<(), MprisError> { + self.player_proxy.set_loop_status(loop_status).await + } + + /// Gets the player's current playback status. + /// + /// See also: [MPRIS2 specification about `PlaybackStatus`][playback] and + /// [`PlaybackStatus`][PlaybackStatus]. + /// + /// [playback]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Property:PlaybackStatus + pub async fn playback_status(&self) -> Result { + self.player_proxy.playback_status().await + } + + /// Signals the player to open the given `uri`. + /// + /// The argument's uri scheme should be in + /// [`supported_uri_schemes()`][Self::supported_uri_schemes] and the mime-type should be in + /// [`supported_mime_types()`][Self::supported_mime_types] but note that this method does not + /// check for that. It's up to you to verify that. + /// + /// > If the playback is stopped, starts playing + /// > + /// > If the uri scheme or the mime-type of the uri to open is not supported, this method does + /// > nothing and may raise an error. In particular, if the list of available uri schemes is + /// > empty, this method may not be implemented. + /// > + /// > Clients should not assume that the Uri has been opened as soon as this method returns. + /// > They should wait until the mpris:trackid field in the Metadata property changes. + /// > + /// > If the media player implements the [TrackList + /// > interface][Self#orgmprismediaplayer2tracklist], then the opened track should be made part + /// > of the tracklist. + /// + /// See also: [MPRIS2 specification about `OpenUri`][uri]. + /// + /// [uri]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Method:OpenUri + pub async fn open_uri(&self, uri: &str) -> Result<(), MprisError> { + self.player_proxy.open_uri(uri).await + } + + /// Gets the minimum allowed value for playback rate. + /// + /// > The minimum value which the Rate property can take. Clients should not attempt to set the + /// > Rate property below this value. + /// > + /// > Note that even if this value is 0.0 or negative, clients should not attempt to set the + /// > Rate property to 0.0. + /// > + /// > This value should always be 1.0 or less. + /// + /// See also: [MPRIS2 specification about `MinimumRate`][min_rate] and + /// [`set_playback_rate()`][Self::set_playback_rate]. + /// + /// [min_rate]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Property:MinimumRate + pub async fn maximum_rate(&self) -> Result { + self.player_proxy.maximum_rate().await + } + + /// Gets the maximum allowed value for playback rate. + /// + /// > The maximum value which the Rate property can take. Clients should not attempt to set the + /// > Rate property above this value. + /// > + /// > This value should always be 1.0 or greater. + /// + /// See also: [MPRIS2 specification about `MaximumRate`][max_rate] and + /// [`set_playback_rate()`][Self::set_playback_rate]. + /// + /// [max_rate]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Property:MaximumRate + pub async fn minimum_rate(&self) -> Result { + self.player_proxy.minimum_rate().await + } + + /// Returns the player's MPRIS (playback) [`rate`][rate] as a factor. + /// + /// 1.0 would mean normal rate, while 2.0 would mean twice the playback speed. + /// + /// See also: [`set_playback_rate()`][Self::set_playback_rate]. + /// + /// [rate]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Property:Rate + pub async fn get_playback_rate(&self) -> Result { + self.player_proxy.rate().await + } + + /// Sets the player's MPRIS (playback) [`rate`][rate] as a factor. + /// + /// > The value must fall in the range described by [`MinimumRate`][Self::minimum_rate] and + /// > [`MaximumRate`][Self::maximum_rate], and must not be 0.0. + /// > + /// > Not all values may be accepted by the media player. It is left to media player + /// > implementations to decide how to deal with values they cannot use; they may either ignore + /// > them or pick a "best fit" value. Clients are recommended to only use sensible fractions or + /// > multiples of 1 (eg: 0.5, 0.25, 1.5, 2.0, etc). + /// + /// **Note**: this method does not check if the argument is between the minimum and maximum. + /// + /// See also: [`get_playback_rate()`][Self::get_playback_rate]. + /// + /// [rate]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Property:Rate + pub async fn set_playback_rate(&self, rate: f64) -> Result<(), MprisError> { + if rate == 0.0 { + return Err(MprisError::InvalidArgument("rate can't be 0.0".to_string())); + } + self.player_proxy.set_rate(rate).await + } + + /// Gets the player's [`Shuffle`][shuffle] property. + /// + /// > A value of false indicates that playback is progressing linearly through a playlist, + /// > while true means playback is progressing through a playlist in some other order. + /// + /// See also: [`set_shuffle()`][Self::set_shuffle]. + /// + /// [shuffle]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Property:Shuffle + pub async fn get_shuffle(&self) -> Result { + self.player_proxy.shuffle().await + } + + /// Sets the [`Shuffle`][shuffle] property of the player. + /// + /// > If CanControl is false, attempting to set this property should have no effect and raise an + /// > error. + /// + /// See also: [`get_shuffle()`][Self::get_shuffle]. + /// + /// [shuffle]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Property:Shuffle + pub async fn set_shuffle(&self, shuffle: bool) -> Result<(), MprisError> { + self.player_proxy.set_shuffle(shuffle).await + } + + /// Gets the [`Volume`][vol] of the player. + /// + /// See also: [`set_volume()`][Self::set_volume]. + /// + /// [vol]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Property:Volume + pub async fn get_volume(&self) -> Result { + self.player_proxy.volume().await + } + + /// Sets the [`Volume`][vol] of the player. + /// + /// Volume should be between 0.0 and 1.0. Above 1.0 is possible, but not + /// recommended. Negative values will be turned into 0.0 + /// + /// See also: [`get_volume()`][Self::get_volume]. + /// + /// [vol]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Property:Volume + pub async fn set_volume(&self, volume: f64) -> Result<(), MprisError> { + self.player_proxy.set_volume(volume).await + } + + /// Shortcut to check if `self.playlist_proxy` is Some + fn check_playlist_support(&self) -> Result<&PlaylistsInterface, MprisError> { + match &self.playlist_interface { + Some(proxy) => Ok(proxy), + None => Err(MprisError::Unsupported), + } + } + + /// Tries to update the given [`Playlist`]. + /// + /// Returns [`true`] if the given [`Playlist`] was found, [`false`] if [`Player`] isn't aware of + /// that playlist. Running [`get_playlists()`][Self::get_playlists] will refresh the list of + /// known playlists. + /// + /// Can only fail if the interface is not implemented. + pub fn update_playlist(&self, playlist: &mut Playlist) -> Result { + Ok(self + .check_playlist_support()? + .update_playlist_struct(playlist)) + } + + /// Signals the player to activate a given [`Playlist`]. + /// + /// > Starts playing the given playlist. + /// > + /// > It is up to the media player whether this completely replaces the current tracklist, or + /// > whether it is merely inserted into the tracklist and the first track starts. For example, + /// > if the media player is operating in a "jukebox" mode, it may just append the playlist to + /// > the list of upcoming tracks, and skip to the first track in the playlist. + /// + /// See also: [MPRIS2 specification about `ActivatePlaylist`][activate] and + /// [`get_playlists()`][Self::get_playlists] + /// + /// [activate]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Playlists_Interface.html#Method:ActivatePlaylist + pub async fn activate_playlist(&self, playlist: &Playlist) -> Result<(), MprisError> { + self.check_playlist_support()? + .activate_playlist(playlist) + .await + } + + /// Gets the [`Playlist`]s of the player. + /// + /// `start_index` and `max_count` allow for pagination of the playlists in case the player has a + /// lot of them. + /// The given [`PlaylistOrdering`] should be in the return value of + /// [`orderings()`][Self::orderings] but this method does not check for that. + /// + /// See also: [MPRIS2 specification about `GetPlaylists`][get_playlists]. + /// + /// [get_playlists]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Playlists_Interface.html#Method:GetPlaylists + pub async fn get_playlists( + &self, + start_index: u32, + max_count: u32, + order: PlaylistOrdering, + reverse_order: bool, + ) -> Result, MprisError> { + self.check_playlist_support()? + .get_playlists(start_index, max_count, order, reverse_order) + .await + } + + /// Clears the stored [`Playlist`]s metadata. + /// + /// Players don't signal when a [`Playlist`] gets removed meaning that if you use a [`Player`] + /// instance for a long time and edit playlists often the internal playlist list will keep + /// getting bigger with pointless data. This method lets you clear it if it becomes an issue. + /// + /// It's recommended to run [`get_playlists()`][Self::get_playlists] after clearing. + /// + /// Can only fail if the interface is not implemented. + pub fn clear_playlists_data(&self) -> Result<(), MprisError> { + self.check_playlist_support()?.clear(); + Ok(()) + } + + /// Gets the currently active [`Playlist`] if any. + /// + /// > Note that this may not have a value even after ActivatePlaylist is called with a valid + /// > playlist id as ActivatePlaylist implementations have the option of simply inserting the + /// > contents of the playlist into the current tracklist. + /// + /// See also: [MPRIS2 specification about `ActivePlaylist`][active]. + /// + /// [active]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Playlists_Interface.html#Property:ActivePlaylist + pub async fn active_playlist(&self) -> Result, MprisError> { + self.check_playlist_support()?.active_playlist().await + } + + /// Gets the [`PlaylistOrdering`]s the player supports. + /// + /// > The available orderings. At least one must be offered. + /// + /// See also: [MPRIS2 specification about `Orderings`][orderings]. + /// + /// [orderings]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Playlists_Interface.html#Property:Orderings + pub async fn orderings(&self) -> Result, MprisError> { + self.check_playlist_support()?.orderings().await + } + + /// Gets the number of available playlists. + /// + /// See also: [MPRIS2 specification about `PlaylistCount`][count]. + /// + /// [count]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Playlists_Interface.html#Property:PlaylistCount + pub async fn playlist_count(&self) -> Result { + self.check_playlist_support()?.playlist_count().await + } + + /// Shortcut to check if `self.track_list_proxy` is Some + fn check_track_list_support(&self) -> Result<&TrackListProxy, MprisError> { + match &self.track_list_proxy { + Some(proxy) => Ok(proxy), + None => Err(MprisError::Unsupported), + } + } + + /// Queries the player to see if it allows changes to its `TrackList`. + /// + /// > If false, calling AddTrack or RemoveTrack will have no effect, and may raise a + /// > NotSupported error. + /// + /// See also: [MPRIS2 specification about `CanEditTracks`][can_edit], + /// [`add_track()`][Self::add_track] and [`remove_track()`][Self::remove_track] . + /// + /// [can_edit]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Track_List_Interface.html#Property:CanEditTracks + pub async fn can_edit_tracks(&self) -> Result { + self.check_track_list_support()?.can_edit_tracks().await + } + + /// Gets the tracks in the current `TrackList` + /// + /// > An array which contains the identifier of each track in the tracklist, in order. + /// + /// See also: [MPRIS2 specification about `Tracks`][tracks]. + /// + /// [tracks]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Track_List_Interface.html#Property:Tracks + pub async fn tracks(&self) -> Result, MprisError> { + self.check_track_list_support()?.tracks().await + } + + /// Adds a `uri` to the `TrackList` and optionally set it as current. + /// + /// The `uri` argument's uri scheme should be in + /// [`supported_uri_schemes()`][Self::supported_uri_schemes] and the mime-type should be in + /// [`supported_mime_types()`][Self::supported_mime_types] but note that this method does not + /// check for that. It's up to you to verify that. + /// + /// It is placed after the specified [`TrackID`], if supported by the player. If [`None`] is + /// used then it will be inserted as the first song. + /// + /// > If the CanEditTracks property is false, this has no effect. + /// > + /// > Note: Clients should not assume that the track has been added at the time when this method + /// > returns. + /// + /// See also: [MPRIS2 specification about `AddTrack`][add_track] and + /// [`can_edit_tracks()`][Self::can_edit_tracks]. + /// + /// [add_track]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Track_List_Interface.html#Method:AddTrack + pub async fn add_track( + &self, + uri: &str, + after_track: Option<&TrackID>, + set_as_current: bool, + ) -> Result<(), MprisError> { + let no_track = TrackID::no_track(); + let after = if let Some(track_id) = after_track { + track_id + } else { + &no_track + }; + self.check_track_list_support()? + .add_track(uri, after, set_as_current) + .await + } + + /// Removes an item from the TrackList. + /// + /// The special [`"NoTrack"`][TrackID::NO_TRACK] value is not allowed as the argument. + /// + /// > If the track is not part of this tracklist, this has no effect. + /// > + /// > If the CanEditTracks property is false, this has no effect. + /// > + /// > Note: Clients should not assume that the track has been removed at the time when this + /// > method returns. + /// + /// See also: [MPRIS2 specification about `RemoveTrack`][remove] and + /// [`can_edit_tracks()`][Self::can_edit_tracks]. + /// + /// [remove]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Track_List_Interface.html#Method:RemoveTrack + pub async fn remove_track(&self, track_id: &TrackID) -> Result<(), MprisError> { + if track_id.is_no_track() { + return Err(MprisError::track_id_is_no_track()); + } + self.check_track_list_support()? + .remove_track(track_id) + .await + } + + /// Go to a specific track on the [`Player`]'s `TrackList`. + /// + /// If the given [`TrackID`] is not part of the player's `TrackList` it will have no effect. + /// The special [`"NoTrack"`][TrackID::NO_TRACK] value is not allowed as the argument. + /// + /// See also: [MPRIS2 specification about `GoTo`][go_to]. + /// + /// [go_to]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Track_List_Interface.html#Method:GoTo + pub async fn go_to(&self, track_id: &TrackID) -> Result<(), MprisError> { + if track_id.is_no_track() { + return Err(MprisError::track_id_is_no_track()); + } + self.check_track_list_support()?.go_to(track_id).await + } + + /// Gets the [`Metadata`] for the given [`TrackID`]s. + /// + /// Will fail if any of the tracks has invalid metadata. + /// + /// See also: [MPRIS2 specification about `GetTracksMetadata`][get_meta]. + /// + /// [get_meta]: + /// https://specifications.freedesktop.org/mpris-spec/latest/Track_List_Interface.html#Method:GetTracksMetadata + pub async fn get_tracks_metadata( + &self, + tracks: &[TrackID], + ) -> Result, MprisError> { + let result = self + .check_track_list_support()? + .get_tracks_metadata(tracks) + .await?; + + let mut metadata = Vec::with_capacity(tracks.len()); + for meta in result { + metadata.push(Metadata::try_from(meta)?); + } + Ok(metadata) + } +} + +impl std::fmt::Debug for Player { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Player") + .field("bus_name", &self.bus_name()) + .field("track_list", &self.track_list_proxy.is_some()) + .field("playlists", &self.playlist_interface.is_some()) + .finish_non_exhaustive() + } } diff --git a/src/playlist.rs b/src/playlist.rs new file mode 100644 index 0000000..a58345c --- /dev/null +++ b/src/playlist.rs @@ -0,0 +1,650 @@ +use std::{ + collections::HashMap, + sync::{Arc, Mutex, MutexGuard}, +}; + +use futures_util::StreamExt; +use zbus::{ + names::BusName, + zvariant::{OwnedObjectPath, OwnedValue, Structure, Type, Value}, + Connection, Task, +}; + +use crate::proxies::PlaylistsProxy; +use crate::serde_util::{option_string, serialize_owned_object_path}; +use crate::{InvalidPlaylist, InvalidPlaylistOrdering, MprisError}; + +type InnerPlaylistData = HashMap)>; + +/// A data structure describing a playlist. +/// +/// It represents a [Playlist][playlist] type from the [MediaPlayer2.Playlists][interface] interface. +/// It contains: +/// - the unique id of the playlist: a [valid D-Bus object path][object_path] which, unlike +/// [`TrackID`][crate::TrackID], is not tied to the player's current track list and should +/// stay the same even if the playlist gets edited +/// - the name of the playlist +/// - an optional icon url +/// +/// It can be obtained from [`Player::active_playlist()`][crate::Player::active_playlist] and +/// [`Player::get_playlists()`][crate::Player::get_playlists]. +/// +/// **Note**: the name and icon url will not get updated if they get changed by the player. If they +/// need to be up to date you should use +/// [`Player::update_playlist()`][crate::Player::update_playlist]. +/// +/// [interface]: https://specifications.freedesktop.org/mpris-spec/latest/Playlists_Interface.html +/// [playlist]: +/// https://specifications.freedesktop.org/mpris-spec/latest/Playlists_Interface.html#Struct:Playlist +/// [object_path]: +/// https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-marshaling-object-path +#[derive(Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, Type)] +#[zvariant(signature = "(oss)")] +pub struct Playlist { + #[serde(serialize_with = "serialize_owned_object_path")] + /// Unique playlist identifier + id: OwnedObjectPath, + name: String, + #[serde(default, with = "option_string")] + icon: Option, +} + +impl Playlist { + /// Tries to create a new [`Playlist`]. + /// + /// The MPRIS spec does not provide a way to create new playlists so you can't use this to + /// create a new one but creating [`Playlist`] manually is useful for example when you save the + /// id to a file and use that to quickly change to a playlist without the need to fetch them + /// first. + /// + /// Will return [`Err`] if the given `id` is not a valid Object Path. See the [struct + /// documentation for details][Playlist]. + /// + /// **Note**: the icon [`String`] should be a valid url but it is not actually checked + /// + /// See also [`new_from_object_path()`][Self::new_from_object_path] + pub fn new(id: String, name: String, icon: Option) -> Result { + match OwnedObjectPath::try_from(id) { + Ok(o) => Ok(Self { id: o, name, icon }), + Err(e) => Err(InvalidPlaylist::from(e.to_string())), + } + } + /// Creates a new [`Playlist`] + /// + /// Almost the same as [`new()`][Self::new] but uses a [`OwnedObjectPath`] instead of a + /// [`String`] so it can't fail. + pub fn new_from_object_path(id: OwnedObjectPath, name: String, icon: Option) -> Self { + Self { id, name, icon } + } + + /// Gets the name of the playlist + /// + /// **Note**: as mentioned in the struct documentation this value might not be correct if the + /// player changed the name of this playlist. Use + /// [`Player::update_playlist()`][crate::Player::update_playlist] to update the values. + pub fn get_name(&self) -> &str { + &self.name + } + + /// Gets the icon url if present + /// + /// **Note**: as mentioned in the struct documentation this value might not be correct if the + /// player changed the icon of this playlist. Use + /// [`Player::update_playlist()`][crate::Player::update_playlist] to update the values. + pub fn get_icon(&self) -> Option<&str> { + self.icon.as_deref() + } + + /// Gets the `id` as a borrowed [`ObjectPath`] + pub(crate) fn get_path(&self) -> &OwnedObjectPath { + &self.id + } + + /// Gets the `id` as a &[`str`] + pub fn get_id(&self) -> &str { + self.id.as_str() + } +} + +/// Represents the [Playlists interface][playlists]. +/// +/// Listens to the PlaylistChanged signal and updates the data internally to allow [`Playlist`]s to +/// update. +/// +/// [playlists]: https://specifications.freedesktop.org/mpris-spec/latest/Playlists_Interface.html +#[derive(Debug, Clone)] +pub(crate) struct PlaylistsInterface { + inner: PlaylistInner, + proxy: PlaylistsProxy<'static>, + // Just here to stop the task when it gets dropped + // Arc is needed to allow cloning + #[allow(dead_code)] + task: Arc>, +} + +impl PlaylistsInterface { + pub(crate) async fn new(conn: &Connection, bus_name: BusName<'static>) -> zbus::Result { + let inner = PlaylistInner::default(); + let i_clone = inner.clone(); + let proxy = PlaylistsProxy::new(conn, bus_name.clone()).await?; + let mut stream = proxy.receive_playlist_changed().await?; + + let task = proxy.inner().connection().executor().spawn( + async move { + while let Some(change) = stream.next().await { + // TODO: don't ignore errors somehow without panicking + if let Ok(args) = change.args() { + let playlist = args.playlist; + i_clone + .get_lock() + .insert(playlist.id, (playlist.name, playlist.icon)); + } + } + }, + &format!("{} playlist watcher task", bus_name.as_str()), + ); + Ok(Self { + inner, + proxy, + task: Arc::new(task), + }) + } + + /// Clears the internal playlist storage. + pub(crate) fn clear(&self) { + self.inner.get_lock().clear(); + } + + /// Updates the given [`Playlist`]. + /// + /// Returns `true` if the given playlist was found else false. + pub(crate) fn update_playlist_struct(&self, playlist: &mut Playlist) -> bool { + let lock = self.inner.get_lock(); + match lock.get(&playlist.id) { + Some((name, icon)) => { + if playlist.name != *name { + playlist.name = name.clone(); + } + if playlist.icon != *icon { + playlist.icon = icon.clone(); + } + true + } + None => false, + } + } + + pub(crate) async fn activate_playlist(&self, playlist: &Playlist) -> Result<(), MprisError> { + self.proxy.activate_playlist(playlist.get_path()).await + } + + /// Wraps the proxy method of the same name and updates the internal data. + pub(crate) async fn get_playlists( + &self, + start_index: u32, + max_count: u32, + order: PlaylistOrdering, + reverse_order: bool, + ) -> Result, MprisError> { + let playlists: Vec<_> = self + .proxy + .get_playlists(start_index, max_count, order.as_str_value(), reverse_order) + .await?; + self.inner.update_playlists(&playlists); + Ok(playlists) + } + + /// Wraps the proxy method of the same name and updates the internal data. + pub(crate) async fn active_playlist(&self) -> Result, MprisError> { + Ok(match self.proxy.active_playlist().await? { + Some(playlist) => { + // Better to create a temporary Vec here than to lock the Mutex for each playlist + let mut playlist = vec![playlist]; + self.inner.update_playlists(&playlist); + Some(playlist.pop().expect("there should be at least 1 playlist")) + } + None => None, + }) + } + + pub(crate) async fn orderings(&self) -> Result, MprisError> { + self.proxy.orderings().await + } + + pub(crate) async fn playlist_count(&self) -> Result { + self.proxy.playlist_count().await + } +} + +#[derive(Debug, Clone, Default)] +struct PlaylistInner { + data: Arc>, +} + +impl PlaylistInner { + fn get_lock(&self) -> MutexGuard { + self.data.lock().expect("poisoned lock") + } + + /// Updates the inner data with the given [`Playlist`]s. + fn update_playlists(&self, playlists: &[Playlist]) { + let mut lock = self.get_lock(); + for playlist in playlists { + lock.insert( + playlist.id.clone(), + (playlist.name.clone(), playlist.icon.clone()), + ); + } + } +} + +impl std::fmt::Debug for Playlist { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Playlist") + .field("id", &self.get_id()) + .field("name", &self.name) + .field("icon", &self.icon) + .finish() + } +} + +impl From<(OwnedObjectPath, String, String)> for Playlist { + fn from(value: (OwnedObjectPath, String, String)) -> Self { + let icon = if value.2.is_empty() { + None + } else { + Some(value.2) + }; + Self { + id: value.0, + name: value.1, + icon, + } + } +} + +impl TryFrom> for Playlist { + type Error = InvalidPlaylist; + + fn try_from(value: Value<'_>) -> Result { + match value { + Value::Structure(structure) => Self::try_from(structure), + _ => Err(InvalidPlaylist::expected("Value::Structure")), + } + } +} + +impl TryFrom for Playlist { + type Error = InvalidPlaylist; + + fn try_from(value: OwnedValue) -> Result { + Self::try_from(Value::from(value)) + } +} + +impl TryFrom> for Playlist { + type Error = InvalidPlaylist; + + fn try_from(value: Structure<'_>) -> Result { + if value.full_signature() == "(oss)" { + if let Ok((id, name, icon)) = <(OwnedObjectPath, String, String)>::try_from(value) { + return Ok(Playlist::from((id, name, icon))); + } + } + Err(InvalidPlaylist::from("incorrect signature")) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +/// Specifies the ordering of returned playlists. +pub enum PlaylistOrdering { + /// Alphabetical ordering by name, ascending. + Alphabetical, /* Alphabetical */ + + /// Ordering by creation date, oldest first. + CreationDate, /* Created */ + + /// Ordering by last modified date, oldest first. + ModifiedDate, /* Modified */ + + ///Ordering by date of last playback, oldest first. + LastPlayDate, /* Played */ + + /// A user-defined ordering. + /// + /// Some media players may allow users to order playlists as they wish. This ordering allows playlists to be retreived in that order. + UserDefined, /* User */ +} + +impl PlaylistOrdering { + /// Returns the string value that's used on the D-Bus. + /// + /// See [`as_str()`][Self::as_str()] if you want the name of the enum variant. + pub fn as_str_value(&self) -> &'static str { + match self { + PlaylistOrdering::Alphabetical => "Alphabetical", + PlaylistOrdering::CreationDate => "Created", + PlaylistOrdering::ModifiedDate => "Modified", + PlaylistOrdering::LastPlayDate => "Played", + PlaylistOrdering::UserDefined => "User", + } + } + + /// Returns the name of the enum variant as a &[str] + /// + /// See [`as_str_value()`][Self::as_str_value] if you want the actual D-Bus value. + pub fn as_str(&self) -> &str { + match self { + PlaylistOrdering::Alphabetical => "Alphabetical", + PlaylistOrdering::CreationDate => "CreationDate", + PlaylistOrdering::ModifiedDate => "ModifiedDate", + PlaylistOrdering::LastPlayDate => "LastPlayDate", + PlaylistOrdering::UserDefined => "UserDefined", + } + } +} + +impl std::fmt::Display for PlaylistOrdering { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl std::str::FromStr for PlaylistOrdering { + type Err = InvalidPlaylistOrdering; + + fn from_str(s: &str) -> Result { + match s { + "Alphabetical" => Ok(Self::Alphabetical), + "Created" => Ok(Self::CreationDate), + "Modified" => Ok(Self::ModifiedDate), + "Played" => Ok(Self::LastPlayDate), + "User" => Ok(Self::UserDefined), + _ => Err(InvalidPlaylistOrdering::from( + r#"expected "Alphabetical", "Created", "Modified", "Played" or "User""#, + )), + } + } +} + +impl TryFrom> for PlaylistOrdering { + type Error = InvalidPlaylistOrdering; + + fn try_from(value: Value<'_>) -> Result { + match value { + Value::Str(s) => s.parse(), + _ => Err(InvalidPlaylistOrdering::expected("Value::Str")), + } + } +} + +impl TryFrom for PlaylistOrdering { + type Error = InvalidPlaylistOrdering; + + fn try_from(value: OwnedValue) -> Result { + Self::try_from(Value::from(value)) + } +} + +#[cfg(test)] +mod playlist_ordering_tests { + use zbus::zvariant::{Dict, ObjectPath, Signature, Structure}; + + use super::*; + + #[test] + fn parsing() { + assert_eq!("Alphabetical".parse(), Ok(PlaylistOrdering::Alphabetical)); + assert_eq!("Created".parse(), Ok(PlaylistOrdering::CreationDate)); + assert_eq!("Modified".parse(), Ok(PlaylistOrdering::ModifiedDate)); + assert_eq!("Played".parse(), Ok(PlaylistOrdering::LastPlayDate)); + assert_eq!("User".parse(), Ok(PlaylistOrdering::UserDefined)); + + assert!("alphabetical".parse::().is_err()); + assert!("created".parse::().is_err()); + assert!("modified".parse::().is_err()); + assert!("played".parse::().is_err()); + assert!("user".parse::().is_err()); + assert!("wrong".parse::().is_err()); + assert!("".parse::().is_err()) + } + + #[test] + fn as_str() { + assert_eq!( + PlaylistOrdering::Alphabetical.as_str_value(), + "Alphabetical" + ); + assert_eq!(PlaylistOrdering::CreationDate.as_str_value(), "Created"); + assert_eq!(PlaylistOrdering::ModifiedDate.as_str_value(), "Modified"); + assert_eq!(PlaylistOrdering::LastPlayDate.as_str_value(), "Played"); + assert_eq!(PlaylistOrdering::UserDefined.as_str_value(), "User"); + } + + #[test] + fn display() { + assert_eq!(&PlaylistOrdering::Alphabetical.to_string(), "Alphabetical"); + assert_eq!(&PlaylistOrdering::CreationDate.to_string(), "CreationDate"); + assert_eq!(&PlaylistOrdering::LastPlayDate.to_string(), "LastPlayDate"); + assert_eq!(&PlaylistOrdering::UserDefined.to_string(), "UserDefined"); + } + + #[test] + fn from_value() { + let s_signature = Signature::try_from("s").unwrap(); + + assert!(PlaylistOrdering::try_from(Value::U8(0)).is_err()); + assert!(PlaylistOrdering::try_from(Value::Bool(false)).is_err()); + assert!(PlaylistOrdering::try_from(Value::I16(0)).is_err()); + assert!(PlaylistOrdering::try_from(Value::U16(0)).is_err()); + assert!(PlaylistOrdering::try_from(Value::I32(0)).is_err()); + assert!(PlaylistOrdering::try_from(Value::U32(0)).is_err()); + assert!(PlaylistOrdering::try_from(Value::I64(0)).is_err()); + assert!(PlaylistOrdering::try_from(Value::U64(0)).is_err()); + assert!(PlaylistOrdering::try_from(Value::F64(0.0)).is_err()); + + assert_eq!( + PlaylistOrdering::try_from(Value::Str("Alphabetical".into())), + Ok(PlaylistOrdering::Alphabetical) + ); + assert_eq!( + PlaylistOrdering::try_from(Value::Str("Created".into())), + Ok(PlaylistOrdering::CreationDate) + ); + assert_eq!( + PlaylistOrdering::try_from(Value::Str("Played".into())), + Ok(PlaylistOrdering::LastPlayDate) + ); + assert_eq!( + PlaylistOrdering::try_from(Value::Str("User".into())), + Ok(PlaylistOrdering::UserDefined) + ); + + assert!(PlaylistOrdering::try_from(Value::Str("Wrong".into())).is_err()); + assert!(PlaylistOrdering::try_from(Value::Signature(s_signature.clone())).is_err()); + assert!(PlaylistOrdering::try_from(Value::ObjectPath(ObjectPath::default())).is_err()); + assert!(PlaylistOrdering::try_from(Value::Value(Box::new(Value::Bool(false)))).is_err()); + assert!(PlaylistOrdering::try_from(Value::Array(vec![0].try_into().unwrap())).is_err()); + assert!(PlaylistOrdering::try_from(Value::Dict(Dict::new( + s_signature.clone(), + s_signature.clone() + ))) + .is_err()); + assert!(PlaylistOrdering::try_from(Value::Structure(Structure::default())).is_err()); + } +} + +#[cfg(test)] +mod playlist_tests { + use super::*; + use zbus::zvariant::{Dict, ObjectPath, Signature}; + + #[test] + fn new() { + let manual = Playlist { + id: ObjectPath::from_string_unchecked(String::from("/valid/path")).into(), + name: String::from("TestName"), + icon: Some(String::from("TestIcon")), + }; + let new = Playlist::new( + String::from("/valid/path"), + String::from("TestName"), + Some(String::from("TestIcon")), + ); + assert_eq!(new, Ok(manual)); + } + + #[test] + fn gets() { + let mut new = Playlist::new_from_object_path( + ObjectPath::from_string_unchecked(String::from("/valid/path")).into(), + String::from("TestName"), + Some(String::from("TestIcon")), + ); + assert_eq!(new.get_name(), "TestName"); + assert_eq!(new.get_icon(), Some("TestIcon")); + assert_eq!( + new.get_path().as_ref(), + ObjectPath::from_str_unchecked("/valid/path") + ); + assert_eq!(new.get_id(), "/valid/path"); + + new.icon = None; + assert_eq!(new.get_icon(), None); + } + + #[test] + fn from_value_fail() { + let s_signature = Signature::try_from("s").unwrap(); + + assert!(Playlist::try_from(Value::U8(0)).is_err()); + assert!(Playlist::try_from(Value::Bool(false)).is_err()); + assert!(Playlist::try_from(Value::I16(0)).is_err()); + assert!(Playlist::try_from(Value::U16(0)).is_err()); + assert!(Playlist::try_from(Value::I32(0)).is_err()); + assert!(Playlist::try_from(Value::U32(0)).is_err()); + assert!(Playlist::try_from(Value::I64(0)).is_err()); + assert!(Playlist::try_from(Value::U64(0)).is_err()); + assert!(Playlist::try_from(Value::F64(0.0)).is_err()); + assert!(Playlist::try_from(Value::Str("".into())).is_err()); + assert!(Playlist::try_from(Value::Signature(s_signature.clone())).is_err()); + assert!(Playlist::try_from(Value::ObjectPath(ObjectPath::default())).is_err()); + assert!(Playlist::try_from(Value::Value(Box::new(Value::Bool(false)))).is_err()); + assert!(Playlist::try_from(Value::Array(vec![0].try_into().unwrap())).is_err()); + assert!(Playlist::try_from(Value::Dict(Dict::new( + s_signature.clone(), + s_signature.clone() + ))) + .is_err()); + } + + #[test] + fn from_value_structure() { + assert!(Playlist::try_from(Value::Structure(Structure::default())).is_err()); + + let valid_structure = + Structure::from((ObjectPath::from_str_unchecked("/valid"), "Name", "")); + assert_eq!( + Playlist::try_from(valid_structure), + Ok(Playlist { + id: ObjectPath::from_str_unchecked("/valid").into(), + name: String::from("Name"), + icon: None + }) + ); + + let wrong_signature = Structure::from((ObjectPath::from_str_unchecked("/valid"), 0, "")); + assert!(Playlist::try_from(wrong_signature).is_err()); + + let too_long = Structure::from((ObjectPath::from_str_unchecked("/valid"), "", "", "")); + assert!(Playlist::try_from(too_long).is_err()); + } +} + +#[cfg(test)] +mod playlist_serde_tests { + use super::*; + use serde_test::{assert_de_tokens, assert_de_tokens_error, assert_tokens, Token}; + use zbus::zvariant::ObjectPath; + + #[test] + fn serialization() { + let mut playlist = Playlist::new_from_object_path( + ObjectPath::from_string_unchecked(String::from("/valid/path")).into(), + String::from("TestName"), + Some(String::from("TestIcon")), + ); + assert_tokens( + &playlist, + &[ + Token::Struct { + name: "Playlist", + len: 3, + }, + Token::Str("id"), + Token::String("/valid/path"), + Token::Str("name"), + Token::String("TestName"), + Token::Str("icon"), + Token::String("TestIcon"), + Token::StructEnd, + ], + ); + + playlist.icon = None; + assert_tokens( + &playlist, + &[ + Token::Struct { + name: "Playlist", + len: 3, + }, + Token::Str("id"), + Token::String("/valid/path"), + Token::Str("name"), + Token::String("TestName"), + Token::Str("icon"), + Token::Str(""), + Token::StructEnd, + ], + ); + } + + #[test] + fn deser_default() { + let playlist = Playlist::new_from_object_path( + ObjectPath::from_str_unchecked("/valid/path").into(), + String::from("TestName"), + None, + ); + assert_de_tokens( + &playlist, + &[ + Token::Struct { + name: "Playlist", + len: 3, + }, + Token::Str("id"), + Token::String("/valid/path"), + Token::Str("name"), + Token::String("TestName"), + Token::StructEnd, + ], + ); + } + + #[test] + fn deser_invalid_path() { + assert_de_tokens_error::( + &[ + Token::Struct { + name: "Playlist", + len: 3, + }, + Token::Str("id"), + Token::String("invalid/path"), + ], + "invalid value: character `i`, expected /", + ); + } +} diff --git a/src/proxies.rs b/src/proxies.rs index ae7bc16..91c7a3e 100644 --- a/src/proxies.rs +++ b/src/proxies.rs @@ -1,37 +1,318 @@ use std::collections::HashMap; -use zbus::dbus_proxy; -use zbus::zvariant::Value; +use zbus::names::{BusName, OwnedUniqueName}; +use zbus::proxy; +use zbus::zvariant::{OwnedObjectPath, OwnedValue}; -#[dbus_proxy( +use crate::{metadata::RawMetadata, MprisDuration, MprisError, TrackID}; +use crate::{LoopStatus, PlaybackStatus, Playlist, PlaylistOrdering}; + +#[proxy( default_service = "org.freedesktop.DBus", interface = "org.freedesktop.DBus", - default_path = "/org/freedesktop/DBus" + default_path = "/org/freedesktop/DBus", + gen_blocking = false )] pub(crate) trait DBus { - fn list_names(&self) -> zbus::Result>; + fn list_names(&self) -> Result, MprisError>; + + fn get_name_owner(&self, bus_name: &BusName<'_>) -> Result; } -#[dbus_proxy( +#[proxy( interface = "org.mpris.MediaPlayer2", - default_path = "/org/mpris/MediaPlayer2" + default_path = "/org/mpris/MediaPlayer2", + gen_blocking = false )] pub(crate) trait MediaPlayer2 { - #[dbus_proxy(property)] - fn identity(&self) -> zbus::Result; + /// Quit method + fn quit(&self) -> Result<(), MprisError>; + + /// Raise method + fn raise(&self) -> Result<(), MprisError>; + + /// CanQuit property + #[zbus(property)] + fn can_quit(&self) -> Result; + + /// CanRaise property + #[zbus(property)] + fn can_raise(&self) -> Result; + + /// DesktopEntry property + #[zbus(property)] + fn desktop_entry(&self) -> Result; + + /// HasTrackList property + #[zbus(property)] + fn has_track_list(&self) -> Result; + + /// Identity property + #[zbus(property)] + fn identity(&self) -> Result; + + /// SupportedMimeTypes property + #[zbus(property)] + fn supported_mime_types(&self) -> Result, MprisError>; + + /// SupportedUriSchemes property + #[zbus(property)] + fn supported_uri_schemes(&self) -> Result, MprisError>; + + #[zbus(property)] + fn fullscreen(&self) -> Result; + + #[zbus(property)] + fn set_fullscreen(&self, value: bool) -> Result<(), MprisError>; + + #[zbus(property)] + fn can_set_fullscreen(&self) -> Result; } impl MediaPlayer2Proxy<'_> { - pub fn bus_name(&self) -> &str { - self.inner().destination().as_str() + pub(crate) async fn ping(&self) -> Result<(), MprisError> { + self.inner() + .connection() + .call_method( + Some(self.0.destination()), + self.0.path(), + Some("org.freedesktop.DBus.Peer"), + "Ping", + &(), + ) + .await + .map(|_| ()) + .map_err(MprisError::from) } } -#[dbus_proxy( +#[proxy( interface = "org.mpris.MediaPlayer2.Player", - default_path = "/org/mpris/MediaPlayer2" + default_path = "/org/mpris/MediaPlayer2", + gen_blocking = false )] pub(crate) trait Player { - #[dbus_proxy(property)] - fn metadata(&self) -> zbus::Result>; + /// Next method + fn next(&self) -> Result<(), MprisError>; + + /// OpenUri method + fn open_uri(&self, uri: &str) -> Result<(), MprisError>; + + /// Pause method + fn pause(&self) -> Result<(), MprisError>; + + /// Play method + fn play(&self) -> Result<(), MprisError>; + + /// PlayPause method + fn play_pause(&self) -> Result<(), MprisError>; + + /// Previous method + fn previous(&self) -> Result<(), MprisError>; + + /// Seek method + fn seek(&self, offset: i64) -> Result<(), MprisError>; + + /// SetPosition method + fn set_position(&self, track_id: &TrackID, position: MprisDuration) -> Result<(), MprisError>; + + /// Stop method + fn stop(&self) -> Result<(), MprisError>; + + /// Seeked signal + #[zbus(signal)] + fn seeked(&self, position: MprisDuration) -> Result<(), MprisError>; + + /// CanControl property + #[zbus(property(emits_changed_signal = "const"))] + fn can_control(&self) -> Result; + + /// CanGoNext property + #[zbus(property)] + fn can_go_next(&self) -> Result; + + /// CanGoPrevious property + #[zbus(property)] + fn can_go_previous(&self) -> Result; + + /// CanPause property + #[zbus(property)] + fn can_pause(&self) -> Result; + + /// CanPlay property + #[zbus(property)] + fn can_play(&self) -> Result; + + /// CanSeek property + #[zbus(property)] + fn can_seek(&self) -> Result; + + /// LoopStatus property + #[zbus(property)] + fn loop_status(&self) -> Result; + + #[zbus(property)] + fn set_loop_status(&self, value: LoopStatus) -> Result<(), MprisError>; + + /// MaximumRate property + #[zbus(property)] + fn maximum_rate(&self) -> Result; + + /// Metadata property + #[zbus(property)] + fn metadata(&self) -> Result; + + /// MinimumRate property + #[zbus(property)] + fn minimum_rate(&self) -> Result; + + /// PlaybackStatus property + #[zbus(property)] + fn playback_status(&self) -> Result; + + /// Position property + #[zbus(property(emits_changed_signal = "const"))] + fn position(&self) -> Result; + + /// Rate property + #[zbus(property)] + fn rate(&self) -> Result; + + #[zbus(property)] + fn set_rate(&self, value: f64) -> Result<(), MprisError>; + + /// Shuffle property + #[zbus(property)] + fn shuffle(&self) -> Result; + + #[zbus(property)] + fn set_shuffle(&self, value: bool) -> Result<(), MprisError>; + + /// Volume property + #[zbus(property)] + fn volume(&self) -> Result; + + #[zbus(property)] + fn set_volume(&self, value: f64) -> Result<(), MprisError>; +} + +#[proxy( + interface = "org.mpris.MediaPlayer2.Playlists", + default_path = "/org/mpris/MediaPlayer2", + gen_blocking = false +)] +pub(crate) trait Playlists { + /// ActivatePlaylist method + fn activate_playlist(&self, playlist_id: &OwnedObjectPath) -> Result<(), MprisError>; + + /// GetPlaylists method + fn get_playlists( + &self, + index: u32, + max_count: u32, + order: &str, + reverse_order: bool, + ) -> Result, MprisError>; + + #[zbus(signal)] + fn playlist_changed(&self, playlist: Playlist) -> Result<(), MprisError>; + + /// ActivePlaylist property + #[zbus(property, name = "ActivePlaylist")] + fn _active_playlist_inner( + &self, + ) -> Result<(bool, (OwnedObjectPath, String, String)), MprisError>; + + /// Orderings property + #[zbus(property)] + fn orderings(&self) -> Result, MprisError>; + + /// PlaylistCount property + #[zbus(property)] + fn playlist_count(&self) -> Result; +} + +impl PlaylistsProxy<'_> { + pub(crate) async fn active_playlist(&self) -> Result, MprisError> { + Ok(match self._active_playlist_inner().await? { + (true, data) => Some(Playlist::from(data)), + _ => None, + }) + } +} + +#[proxy( + interface = "org.mpris.MediaPlayer2.TrackList", + default_path = "/org/mpris/MediaPlayer2", + gen_blocking = false +)] +pub trait TrackList { + /// AddTrack method + fn add_track( + &self, + uri: &str, + after_track: &TrackID, + set_as_current: bool, + ) -> Result<(), MprisError>; + + // There is no way to implement zvariant::Type for MetadataValue while keeping the current serde + // implementation. If you try to do it then returning RawMetadata here will cause a infinite + // recursion that ends with a stack overflow. To avoid that the function just gets wrapped + /// GetTracksMetadata method + fn _get_tracks_metadata( + &self, + track_ids: &[TrackID], + ) -> Result>, MprisError>; + + /// GoTo method + fn go_to(&self, track_id: &TrackID) -> Result<(), MprisError>; + + /// RemoveTrack method + fn remove_track(&self, track_id: &TrackID) -> Result<(), MprisError>; + + /// TrackAdded signal + #[zbus(signal)] + fn track_added(&self, metadata: RawMetadata, after_track: TrackID) -> Result<(), MprisError>; + + /// TrackListReplaced signal + #[zbus(signal)] + fn track_list_replaced( + &self, + track_ids: Vec, + current_track: TrackID, + ) -> Result<(), MprisError>; + + /// TrackMetadataChanged signal + #[zbus(signal)] + fn track_metadata_changed( + &self, + track_id: TrackID, + metadata: RawMetadata, + ) -> Result<(), MprisError>; + + /// TrackRemoved signal + #[zbus(signal)] + fn track_removed(&self, track_id: TrackID) -> Result<(), MprisError>; + + /// CanEditTracks property + #[zbus(property)] + fn can_edit_tracks(&self) -> Result; + + /// Tracks property + #[zbus(property(emits_changed_signal = "invalidates"))] + fn tracks(&self) -> Result, MprisError>; +} + +impl TrackListProxy<'_> { + pub(crate) async fn get_tracks_metadata( + &self, + tracks: &[TrackID], + ) -> Result, MprisError> { + Ok(self + ._get_tracks_metadata(tracks) + .await? + .into_iter() + .map(RawMetadata::from) + .collect()) + } } diff --git a/src/serde_util.rs b/src/serde_util.rs new file mode 100644 index 0000000..b09db58 --- /dev/null +++ b/src/serde_util.rs @@ -0,0 +1,48 @@ +use serde::{de::IgnoredAny, Deserialize, Deserializer, Serializer}; +use zbus::zvariant::OwnedObjectPath; + +/// Serializes OwnedObjectPath into simple string +pub(crate) fn serialize_owned_object_path( + object: &OwnedObjectPath, + ser: S, +) -> Result +where + S: Serializer, +{ + ser.serialize_str(object.as_str()) +} + +/// Takes anything and returns a unit +pub(crate) fn deser_no_fail<'de, D>(d: D) -> Result<(), D::Error> +where + D: Deserializer<'de>, +{ + IgnoredAny::deserialize(d).map(|_| ()) +} + +/// Deals with Option +pub(crate) mod option_string { + use super::*; + + pub(crate) fn serialize(object: &Option, ser: S) -> Result + where + S: Serializer, + { + ser.serialize_str(match object { + Some(s) => s, + None => "", + }) + } + + pub(crate) fn deserialize<'de, D>(deser: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deser)?; + if s.is_empty() { + Ok(None) + } else { + Ok(Some(s)) + } + } +}