//! The MPRIS player as the desktop sees it: over a real session bus, through //! a real D-Bus client (architecture/mpris.md). //! //! The unit tests in `src/mpris.rs` cover the mapping decisions; this one //! covers the parts only a bus can answer — that the name is claimed, that //! the interfaces are served where the spec says, and that a method call //! becomes a command for the server. //! //! Skipped when there is no session bus, which is the normal state of a CI //! runner. To run it: //! //! ```sh //! devenv shell -- dbus-run-session -- cargo test -p cbd-tui --test mpris_bus //! ``` #![cfg(feature = "mpris")] use std::time::Duration; use cbd_tui::{app::MessageFromUi, mpris}; use crabidy_core::proto::crabidy::{ Album, InitResponse, PlayState, Queue, QueueModifiers, QueueTrack, Track, TrackPosition, }; use mpris_server::zbus::{zvariant::OwnedValue, Connection, Proxy}; const OBJECT_PATH: &str = "/org/mpris/MediaPlayer2"; const PLAYER_INTERFACE: &str = "org.mpris.MediaPlayer2.Player"; const ROOT_INTERFACE: &str = "org.mpris.MediaPlayer2"; fn playing_state() -> InitResponse { InitResponse { queue: Some(Queue { timestamp: 0, current_position: 1, tracks: vec![], resolving: false, }), mods: Some(QueueModifiers { shuffle: false, repeat: false, }), queue_track: Some(QueueTrack { queue_position: 1, track: Some(Track { path: "/fs/music/song.flac".to_string(), artist: "the artist".to_string(), title: "the song".to_string(), duration: Some(240), album: Some(Album { title: "the album".to_string(), release_date: None, }), is_skipped: false, provider_item_id: String::new(), is_captured: false, }), }), play_state: PlayState::Playing.into(), volume: 0.5, mute: false, position: Some(TrackPosition { position: 12_000, duration: 240_000, }), auth_enabled: false, } } /// Waits for the published mirror to catch up with the feed: the state /// travels a channel and a task, so a property read straight after a publish /// may still see the previous value. async fn await_property(proxy: &Proxy<'_>, name: &str, expected: &str) -> OwnedValue { for _ in 0..100 { let value: OwnedValue = proxy.get_property(name).await.expect("read property"); if format!("{value:?}").contains(expected) { return value; } tokio::time::sleep(Duration::from_millis(20)).await; } panic!("{name} never became {expected}"); } #[tokio::test] async fn the_desktop_sees_the_track_and_its_keys_reach_the_server() { if std::env::var_os("DBUS_SESSION_BUS_ADDRESS").is_none() { eprintln!("no session bus; skipping (see this file's docs)"); return; } let (commands_tx, commands) = flume::unbounded(); let feed = mpris::start(commands_tx) .await .expect("a session bus is available, so the player must register"); feed.publish_init(&playing_state()); let connection = Connection::session().await.expect("connect to the bus"); let bus_name = format!( "org.mpris.MediaPlayer2.crabidy.instance{}", std::process::id() ); let player = Proxy::new(&connection, bus_name.clone(), OBJECT_PATH, PLAYER_INTERFACE) .await .expect("the player interface is served where the spec says"); let root = Proxy::new(&connection, bus_name, OBJECT_PATH, ROOT_INTERFACE) .await .expect("the root interface too"); // What a status bar reads. let identity: String = root.get_property("Identity").await.expect("Identity"); assert_eq!(identity, "crabidy"); await_property(&player, "PlaybackStatus", "Playing").await; let metadata = await_property(&player, "Metadata", "the song").await; let metadata = format!("{metadata:?}"); assert!(metadata.contains("the artist"), "{metadata}"); assert!(metadata.contains("the album"), "{metadata}"); assert!( metadata.contains("/org/crabidy/queue/1"), "the trackid is the queue position: {metadata}" ); assert!( !metadata.contains("/fs/music/song.flac"), "no library path and no URL may reach the bus: {metadata}" ); // What a media key does. Pause, because it is the mapping with a // condition on it: the server only has a toggle. player .call::<_, _, ()>("Pause", &()) .await .expect("Pause is callable"); assert!( matches!( commands.recv_timeout(Duration::from_secs(1)), Ok(MessageFromUi::TogglePlay) ), "the pause key must reach the server as a playback command" ); // And a control the desktop is told it does not have. let can_quit: bool = root.get_property("CanQuit").await.expect("CanQuit"); assert!(!can_quit); assert!( root.call::<_, _, ()>("Quit", &()).await.is_err(), "Quit must be refused, not close the user's terminal" ); }