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),