146 lines
5.1 KiB
Rust
146 lines
5.1 KiB
Rust
//! 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"
|
|
);
|
|
}
|