crabidy/cbd-cli/src/client.rs

374 lines
13 KiB
Rust

//! 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<MetadataValue<tonic::metadata::Ascii>>,
}
impl AuthInterceptor {
fn new(user: &str, password: &str) -> Result<Self, Box<dyn std::error::Error>> {
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<Request<()>, Status> {
if let Some(header) = &self.header {
request
.metadata_mut()
.insert("authorization", header.clone());
}
Ok(request)
}
}
type Client = CrabidyServiceClient<InterceptedService<Channel, AuthInterceptor>>;
/// Connects (lazily) to the server described by `conn`, with a bounded
/// connect timeout and per-request deadline.
async fn connect(conn: &Connection) -> Result<Client, Box<dyn std::error::Error>> {
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<dyn std::error::Error> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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
}