From ec4b7c209ac7113e4424f254478f6ca954eaa8ec Mon Sep 17 00:00:00 2001 From: Henrik Friedrichsen Date: Wed, 28 Dec 2022 19:01:59 +0100 Subject: [PATCH] Create IPC socket on UNIX platforms (#1018) * Create IPC socket on UNIX platforms Creates an IPC socket which remote programs/scripts can connect to. This can be used to control ncspot or fetch the current playback status. At the moment, only remote control is implemented. Next step is to send the current player status as a JSON object. Fixes #524 * Publish status changes to connected sockets Whenever the playback mode (playing, paused, stopped) or the track changes, all socket listeners will be notified. Fixes #924, fixes #1019 * Document IPC feature --- Cargo.lock | 2 + Cargo.toml | 5 ++- README.md | 25 ++++++++++++ src/events.rs | 1 + src/ipc.rs | 108 +++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 29 ++++++++++++- src/spotify.rs | 2 +- 7 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 src/ipc.rs diff --git a/Cargo.lock b/Cargo.lock index 5644d4a..c0741cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1753,6 +1753,7 @@ dependencies = [ "strum_macros 0.24.3", "tokio", "tokio-stream", + "tokio-util", "toml", "unicode-width", "url", @@ -3237,6 +3238,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ded5802..de4f72d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,8 +40,9 @@ serde_cbor = "0.11.2" serde_json = "1.0" strum = "0.24.1" strum_macros = "0.24.3" -tokio = {version = "1", features = ["rt-multi-thread", "sync", "time"]} -tokio-stream = "0.1.9" +tokio = {version = "1", features = ["rt-multi-thread", "sync", "time", "net"]} +tokio-util = {version = "0.7.4", features = ["codec"]} +tokio-stream = {version = "0.1.11", features = ["sync"]} toml = "0.5" unicode-width = "0.1.9" url = "2.2" diff --git a/README.md b/README.md index 3e73a69..f4fb6dd 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ You **must** have an existing premium Spotify subscription to use `ncspot`. - [Library](#library) - [Vim-Like Search Bar](#vim-like-search-bar) - [Vim-Like Commands](#vim-like-commands) + - [Remote control (IPC)](#remote-control-ipc) - [Configuration](#configuration) - [Custom Keybindings](#custom-keybindings) - [Proxy](#proxy) @@ -325,6 +326,30 @@ Note: \ - mandatory arg; [BAR] - optional arg | `noop` | Do nothing. Useful for disabling default keybindings. See [custom keybindings](#custom-keybindings). | | `reload` | Reload the configuration from disk. See [Configuration](#configuration). | +## Remote control (IPC) + +Apart from MPRIS, ncspot will also create a domain socket on UNIX platforms +(Linux, macOS, *BSD) at `~/.cache/ncspot/ncspot.sock`. Applications or scripts +can connect to this socket to send commands or be notified of the currently +playing track, i.e. with `netcat`: + +``` +% nc -U ~/.cache/ncspot/ncspot.sock +play +{"mode":{"Playing":{"secs_since_epoch":1672249086,"nanos_since_epoch":547517730}},"playable":{"type":"Track","id":"2wcrQZ7ZJolYEfIaPP9yL4","uri":"spotify:track:2wcrQZ7ZJolYEfIaPP9yL4","title":"Hit Me Where It Hurts","track_number":4,"disc_number":1,"duration":184132,"artists":["Caroline Polachek"],"artist_ids":["4Ge8xMJNwt6EEXOzVXju9a"],"album":"Pang","album_id":"4ClyeVlAKJJViIyfVW0yQD","album_artists":["Caroline Polachek"],"cover_url":"https://i.scdn.co/image/ab67616d0000b2737d983e7bf67c2806218c2759","url":"https://open.spotify.com/track/2wcrQZ7ZJolYEfIaPP9yL4","added_at":"2022-12-19T22:41:05Z","list_index":0}} +playpause +{"mode":{"Paused":{"secs":25,"nanos":575000000}},"playable":{"type":"Track","id":"2wcrQZ7ZJolYEfIaPP9yL4","uri":"spotify:track:2wcrQZ7ZJolYEfIaPP9yL4","title":"Hit Me Where It Hurts","track_number":4,"disc_number":1,"duration":184132,"artists":["Caroline Polachek"],"artist_ids":["4Ge8xMJNwt6EEXOzVXju9a"],"album":"Pang","album_id":"4ClyeVlAKJJViIyfVW0yQD","album_artists":["Caroline Polachek"],"cover_url":"https://i.scdn.co/image/ab67616d0000b2737d983e7bf67c2806218c2759","url":"https://open.spotify.com/track/2wcrQZ7ZJolYEfIaPP9yL4","added_at":"2022-12-19T22:41:05Z","list_index":0}} +``` + +Each time the playback status changes (i.e. after sending the `play`/`playpause` +command or simply by playing the queue), the current status will be published as +a JSON structure. + +Possible use cases for this could be: +- Controlling a detached ncspot session (in `tmux` for example) +- Displaying the currently playing track in your favorite application/status bar +- Setting up routines, i.e. to play specific songs/playlists when ncspot starts + ## Configuration Configuration is saved to `~/.config/ncspot/config.toml` (or diff --git a/src/events.rs b/src/events.rs index 652e3e5..9674ca7 100644 --- a/src/events.rs +++ b/src/events.rs @@ -8,6 +8,7 @@ pub enum Event { Player(PlayerEvent), Queue(QueueEvent), SessionDied, + IpcInput(String), } pub type EventSender = Sender; diff --git a/src/ipc.rs b/src/ipc.rs new file mode 100644 index 0000000..54d5847 --- /dev/null +++ b/src/ipc.rs @@ -0,0 +1,108 @@ +use std::{io, path::PathBuf}; + +use futures::SinkExt; +use log::{debug, error, info}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::runtime::Handle; +use tokio::sync::watch::{Receiver, Sender}; +use tokio_stream::wrappers::WatchStream; +use tokio_stream::StreamExt; +use tokio_util::codec::{FramedRead, FramedWrite, LinesCodec}; + +use crate::events::{Event, EventManager}; +use crate::model::playable::Playable; +use crate::spotify::PlayerEvent; + +pub struct IpcSocket { + tx: Sender, +} + +#[derive(Clone, Debug, Serialize)] +struct Status { + mode: PlayerEvent, + playable: Option, +} + +impl IpcSocket { + pub fn new(handle: &Handle, path: PathBuf, ev: EventManager) -> io::Result { + if path.exists() { + std::fs::remove_file(&path)?; + } + + info!("Creating IPC domain socket at {path:?}"); + + let status = Status { + mode: PlayerEvent::Stopped, + playable: None, + }; + + let (tx, rx) = tokio::sync::watch::channel(status); + handle.spawn(async move { + let listener = UnixListener::bind(path).expect("Could not create IPC domain socket"); + Self::worker(listener, ev, rx.clone()).await; + }); + + Ok(IpcSocket { tx }) + } + + pub fn publish(&self, event: &PlayerEvent, playable: Option) { + let status = Status { + mode: event.clone(), + playable, + }; + self.tx.send(status).expect("Error publishing IPC update"); + } + + async fn worker(listener: UnixListener, ev: EventManager, tx: Receiver) { + loop { + match listener.accept().await { + Ok((stream, sockaddr)) => { + debug!("Connection from {:?}", sockaddr); + tokio::spawn(Self::stream_handler( + stream, + ev.clone(), + WatchStream::new(tx.clone()), + )); + } + Err(e) => error!("Error accepting connection: {e}"), + } + } + } + + async fn stream_handler( + mut stream: UnixStream, + ev: EventManager, + mut rx: WatchStream, + ) -> Result<(), String> { + let (reader, writer) = stream.split(); + let mut framed_reader = FramedRead::new(reader, LinesCodec::new()); + let mut framed_writer = FramedWrite::new(writer, LinesCodec::new()); + + loop { + tokio::select! { + line = framed_reader.next() => { + match line { + Some(Ok(line)) => { + debug!("Received line: \"{line}\""); + ev.send(Event::IpcInput(line)); + } + Some(Err(e)) => error!("Error reading line: {e}"), + None => { + debug!("Closing IPC connection"); + return Ok(()) + } + } + } + Some(status) = rx.next() => { + debug!("IPC Status update: {status:?}"); + let status_str = serde_json::to_string(&status).map_err(|e| e.to_string())?; + framed_writer.send(status_str).await.map_err(|e| e.to_string())?; + } + else => { + error!("All streams are closed"); + return Ok(()) + } + } + } + } +} diff --git a/src/main.rs b/src/main.rs index 707699b..2347d67 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,12 +43,15 @@ mod traits; mod ui; mod utils; +#[cfg(unix)] +mod ipc; + #[cfg(feature = "mpris")] mod mpris; use crate::command::{Command, JumpMode}; use crate::commands::CommandManager; -use crate::config::Config; +use crate::config::{cache_path, Config}; use crate::events::{Event, EventManager}; use crate::ext_traits::CursiveExt; use crate::library::Library; @@ -351,6 +354,16 @@ fn main() -> Result<(), String> { #[cfg(unix)] let mut signals = Signals::new([SIGTERM, SIGHUP]).expect("could not register signal handler"); + #[cfg(unix)] + let ipc = { + ipc::IpcSocket::new( + ASYNC_RUNTIME.handle(), + cache_path("ncspot.sock"), + event_manager.clone(), + ) + .map_err(|e| e.to_string())? + }; + // cursive event loop while cursive.is_running() { cursive.step(); @@ -372,6 +385,9 @@ fn main() -> Result<(), String> { #[cfg(feature = "mpris")] mpris_manager.update(); + #[cfg(unix)] + ipc.publish(&state, queue.get_current()); + if state == PlayerEvent::FinishedTrack { queue.next(false); } @@ -380,6 +396,17 @@ fn main() -> Result<(), String> { queue.handle_event(event); } Event::SessionDied => spotify.start_worker(None), + Event::IpcInput(input) => match command::parse(&input) { + Ok(commands) => { + if let Some(data) = cursive.user_data::().cloned() { + for cmd in commands { + info!("Executing command from IPC: {cmd}"); + data.cmd.handle(&mut cursive, cmd); + } + } + } + Err(e) => error!("Parsing error: {e}"), + }, } } } diff --git a/src/spotify.rs b/src/spotify.rs index edb864f..d599e03 100644 --- a/src/spotify.rs +++ b/src/spotify.rs @@ -32,7 +32,7 @@ use crate::ASYNC_RUNTIME; pub const VOLUME_PERCENT: u16 = ((u16::max_value() as f64) * 1.0 / 100.0) as u16; -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub enum PlayerEvent { Playing(SystemTime), Paused(Duration),