//! The gRPC executor for the remote `library`/`queue`/`global` commands //! (`client` feature). Connects a generated `crabidy-core` client with a //! basic-auth interceptor and runs one command against a server, printing a //! human-readable result. //! //! Transport and gRPC `Status` errors are mapped to a short one-line message //! (no color-eyre chain dump) — an ordinary "server unreachable" reads as a //! single line (architecture/cli.md D3, quality gate "remote errors"). use std::time::Duration; use base64::Engine; use tonic::metadata::MetadataValue; use tonic::service::{interceptor::InterceptedService, Interceptor}; use tonic::transport::{Channel, Endpoint}; use tonic::{Request, Status}; use crabidy_core::proto::crabidy::{ crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest, ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest, GetLibraryNodeRequest, InitRequest, InsertRequest, LibraryNode, NextRequest, PrevRequest, Queue, RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest, SetCurrentRequest, StopRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest, Track, }; use crate::{Connection, GlobalCmd, LibraryCmd, QueueCmd, RemoteCmd}; /// The reserved library path the live queue mirrors into; `queue capture` /// captures it (architecture/crabidy-store.md). const CURRENT_NODE: &str = "/crabidy/current"; /// How long to wait for the TCP connect before giving up — a CLI must not /// hang forever against an unreachable server. const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); /// Per-request deadline. Generous: `CaptureLibraryNode` returns once the /// capture is *accepted*; the walk runs server-side. const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); /// Attaches `authorization: Basic …` when a user is configured; an empty user /// means an open server. The header value is a secret and never logged. #[derive(Clone)] pub struct AuthInterceptor { header: Option>, } impl AuthInterceptor { fn new(user: &str, password: &str) -> Result> { if user.is_empty() { return Ok(Self { header: None }); } let encoded = base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}")); let header = format!("Basic {encoded}") .parse() .map_err(|_| "cannot encode credentials header")?; Ok(Self { header: Some(header), }) } } impl Interceptor for AuthInterceptor { fn call(&mut self, mut request: Request<()>) -> Result, Status> { if let Some(header) = &self.header { request .metadata_mut() .insert("authorization", header.clone()); } Ok(request) } } type Client = CrabidyServiceClient>; /// Connects (lazily) to the server described by `conn`, with a bounded /// connect timeout and per-request deadline. async fn connect(conn: &Connection) -> Result> { let endpoint = Endpoint::from_shared(conn.address.clone())? .connect_timeout(CONNECT_TIMEOUT) .timeout(REQUEST_TIMEOUT) .connect_lazy(); let interceptor = AuthInterceptor::new(&conn.user, &conn.password)?; Ok(CrabidyServiceClient::with_interceptor( endpoint, interceptor, )) } /// Maps a gRPC [`Status`] to a short, single-line error — the code plus its /// message, never an internal report chain. A transport failure surfaces as /// `Unavailable` with tonic's one-line reason. fn rpc_error(status: Status) -> Box { let code = status.code(); let message = status.message(); if message.is_empty() { format!("server error: {code}").into() } else { format!("{message} ({code})").into() } } /// Runs one remote command against the server and prints the result. pub async fn run_remote( conn: &Connection, cmd: RemoteCmd, ) -> Result<(), Box> { let mut client = connect(conn).await?; match cmd { RemoteCmd::Library(cmd) => run_library(&mut client, cmd).await, RemoteCmd::Queue(cmd) => run_queue(&mut client, cmd).await, RemoteCmd::Global(cmd) => run_global(&mut client, cmd).await, } } async fn run_library( client: &mut Client, cmd: LibraryCmd, ) -> Result<(), Box> { match cmd { LibraryCmd::List { path } => { let response = client .get_library_node(GetLibraryNodeRequest { path: path.clone() }) .await .map_err(rpc_error)?; match response.into_inner().node { Some(node) => print_node(&node), None => return Err(format!("no such library node: {path}").into()), } } LibraryCmd::Create { parent, title } => { client .create_library_node(CreateLibraryNodeRequest { parent_path: parent.clone(), title: title.clone(), }) .await .map_err(rpc_error)?; println!("created \"{title}\" under {parent}"); } LibraryCmd::Rename { path, title } => { client .rename_library_node(RenameLibraryNodeRequest { path: path.clone(), new_title: title.clone(), }) .await .map_err(rpc_error)?; println!("renamed {path} to \"{title}\""); } LibraryCmd::Delete { path } => { client .delete_library_node(DeleteLibraryNodeRequest { path: path.clone() }) .await .map_err(rpc_error)?; println!("deleted {path}"); } LibraryCmd::Save { path, name } => { capture(client, &path, &name, false).await?; println!("saving {path} as /crabidy/{name} (link)"); } LibraryCmd::Capture { path, name } => { capture(client, &path, &name, true).await?; println!("capturing {path} into /crabidy/{name} (download)"); } } Ok(()) } async fn run_queue(client: &mut Client, cmd: QueueCmd) -> Result<(), Box> { match cmd { QueueCmd::Show => { let response = client.init(InitRequest {}).await.map_err(rpc_error)?; match response.into_inner().queue { Some(queue) => print_queue(&queue), None => println!("the queue is empty"), } } QueueCmd::Append { paths } => { let n = paths.len(); client .append(AppendRequest { paths }) .await .map_err(rpc_error)?; println!("appended {n} path(s)"); } QueueCmd::Insert { position, paths } => { let n = paths.len(); client .insert(InsertRequest { position, paths }) .await .map_err(rpc_error)?; println!("inserted {n} path(s) at {position}"); } QueueCmd::Replace { paths } => { let n = paths.len(); client .replace(ReplaceRequest { paths }) .await .map_err(rpc_error)?; println!("replaced the queue with {n} path(s)"); } QueueCmd::Remove { positions } => { let n = positions.len(); client .remove(RemoveRequest { positions }) .await .map_err(rpc_error)?; println!("removed {n} entry(ies)"); } QueueCmd::Clear { keep_current } => { client .clear_queue(ClearQueueRequest { exclude_current: keep_current, }) .await .map_err(rpc_error)?; println!("cleared the queue"); } QueueCmd::SetCurrent { position } => { client .set_current(SetCurrentRequest { position }) .await .map_err(rpc_error)?; println!("jumped to position {position}"); } QueueCmd::Save { name } => { client .save_queue(SaveQueueRequest { name: name.clone() }) .await .map_err(rpc_error)?; println!("saving the queue as /crabidy/{name} (link)"); } QueueCmd::Capture { name } => { capture(client, CURRENT_NODE, &name, true).await?; println!("capturing the queue into /crabidy/{name} (download)"); } QueueCmd::Shuffle => { client .toggle_shuffle(ToggleShuffleRequest {}) .await .map_err(rpc_error)?; println!("toggled shuffle"); } QueueCmd::Repeat => { client .toggle_repeat(ToggleRepeatRequest {}) .await .map_err(rpc_error)?; println!("toggled repeat"); } } Ok(()) } async fn run_global(client: &mut Client, cmd: GlobalCmd) -> Result<(), Box> { match cmd { GlobalCmd::Play => { client .toggle_play(TogglePlayRequest {}) .await .map_err(rpc_error)?; println!("toggled play/pause"); } GlobalCmd::Stop => { client.stop(StopRequest {}).await.map_err(rpc_error)?; println!("stopped"); } GlobalCmd::Next => { client.next(NextRequest {}).await.map_err(rpc_error)?; println!("next track"); } GlobalCmd::Prev => { client.prev(PrevRequest {}).await.map_err(rpc_error)?; println!("previous track"); } GlobalCmd::Restart => { client .restart_track(RestartTrackRequest {}) .await .map_err(rpc_error)?; println!("restarted the current track"); } GlobalCmd::Mute => { client .toggle_mute(ToggleMuteRequest {}) .await .map_err(rpc_error)?; println!("toggled mute"); } GlobalCmd::Volume { delta } => { client .change_volume(ChangeVolumeRequest { delta }) .await .map_err(rpc_error)?; println!("changed volume by {delta:+}"); } } Ok(()) } /// Issues a `CaptureLibraryNode` (link save or download capture). async fn capture( client: &mut Client, path: &str, name: &str, download: bool, ) -> Result<(), Box> { client .capture_library_node(CaptureLibraryNodeRequest { path: path.to_string(), name: name.to_string(), download, }) .await .map_err(rpc_error)?; Ok(()) } /// Prints a library node: its path/title, child nodes, then tracks. Captured /// rows are marked `*` (architecture/crabidy-store.md). fn print_node(node: &LibraryNode) { let marker = if node.is_captured { " *" } else { "" }; println!("{} \"{}\"{marker}", node.path, node.title); if !node.children.is_empty() { println!("nodes:"); for child in &node.children { let marker = if child.is_captured { " *" } else { "" }; println!(" {} \"{}\"{marker}", child.path, child.title); } } if !node.tracks.is_empty() { println!("tracks:"); for track in &node.tracks { println!(" {} {}", track.path, track_label(track)); } } if node.children.is_empty() && node.tracks.is_empty() { println!("(empty)"); } } /// Prints the current queue with a marker on the current track. fn print_queue(queue: &Queue) { if queue.tracks.is_empty() { println!("the queue is empty"); return; } for (index, track) in queue.tracks.iter().enumerate() { let here = if index as u32 == queue.current_position { ">" } else { " " }; println!("{here} {index:>3} {}", track_label(track)); } } /// A one-line `artist - title` label, marking captured/skipped tracks. fn track_label(track: &Track) -> String { let mut label = if track.artist.is_empty() { track.title.clone() } else { format!("{} - {}", track.artist, track.title) }; if track.is_captured { label.push_str(" *"); } if track.is_skipped { label.push_str(" (skipped)"); } label }