374 lines
13 KiB
Rust
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
|
|
}
|