crabidy/cbd-tui/src/rpc.rs

413 lines
15 KiB
Rust

use crabidy_core::proto::crabidy::{
crabidy_service_client::CrabidyServiceClient, AppendRequest, CaptureLibraryNodeRequest,
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest,
GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest,
InitResponse, InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest,
RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest,
SetCurrentRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest,
ToggleShuffleRequest,
};
use std::{collections::HashMap, error::Error, fmt, time::Duration};
use base64::Engine;
use tonic::{
metadata::MetadataValue,
service::{interceptor::InterceptedService, Interceptor},
transport::{Channel, Endpoint},
Request, Status, Streaming,
};
// FIXME: use anyhow + thiserror
#[derive(Debug)]
enum RpcClientError {
NotFound,
}
impl fmt::Display for RpcClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RpcClientError::NotFound => write!(f, "Requested item not found"),
}
}
}
impl Error for RpcClientError {}
/// Attaches the configured `authorization: Basic …` header to every
/// outgoing request (architecture/roles-auth.md). Without configured
/// credentials it attaches nothing, keeping the zero-config local
/// setup working against 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 {
/// `user` empty means "no credentials".
fn new(user: &str, password: &str) -> Result<Self, Box<dyn 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()
// The value is base64: this cannot fail on credential
// contents, only on programmer error.
.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)
}
}
/// The service client with the auth interceptor baked in.
type Client = CrabidyServiceClient<InterceptedService<Channel, AuthInterceptor>>;
pub struct RpcClient {
library_node_cache: HashMap<String, LibraryNode>,
client: Client,
pub update_stream: Streaming<GetUpdateStreamResponse>,
}
/// Whether a library listing may be served from the session cache.
///
/// The server-side folder providers mutate behind the client's back —
/// saves and captures appear under `/crabidy` (`w`/`W`), files change on
/// disk under `/fs` — so a cached listing turns freshly captured content
/// invisible until a restart. Their listings are cheap local directory
/// walks on the server; always refetch them. Remote provider nodes (tidal,
/// youtube) keep the cache that makes back-navigation instant.
fn is_cacheable(path: &str) -> bool {
const MUTABLE_ROOTS: [&str; 2] = ["/crabidy", "/fs"];
!MUTABLE_ROOTS.iter().any(|root| {
path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))
})
}
impl RpcClient {
pub async fn connect(
server: &'static crate::config::ServerConfig,
) -> Result<RpcClient, Box<dyn Error>> {
let endpoint = Endpoint::from_static(&server.address).connect_lazy();
let interceptor = AuthInterceptor::new(&server.user, &server.password)?;
let mut client = CrabidyServiceClient::with_interceptor(endpoint, interceptor);
let update_stream = Self::get_update_stream(&mut client).await;
let library_node_cache: HashMap<String, LibraryNode> = HashMap::new();
Ok(RpcClient {
client,
library_node_cache,
update_stream,
})
}
async fn get_update_stream(client: &mut Client) -> Streaming<GetUpdateStreamResponse> {
loop {
let get_update_stream_request = Request::new(GetUpdateStreamRequest {});
if let Ok(resp) = client.get_update_stream(get_update_stream_request).await {
return resp.into_inner();
} else {
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
}
pub async fn reconnect_update_stream(&mut self) {
self.update_stream = Self::get_update_stream(&mut self.client).await;
}
pub async fn init(&mut self) -> Result<InitResponse, Box<dyn Error>> {
let init_request = Request::new(InitRequest {});
let response = self.client.init(init_request).await?;
Ok(response.into_inner())
}
pub async fn get_library_node(
&mut self,
path: &str,
) -> Result<Option<&LibraryNode>, Box<dyn Error>> {
if is_cacheable(path) && self.library_node_cache.contains_key(path) {
return Ok(self.library_node_cache.get(path));
}
let get_library_node_request = Request::new(GetLibraryNodeRequest {
path: path.to_string(),
});
let response = self
.client
.get_library_node(get_library_node_request)
.await?;
if let Some(library_node) = response.into_inner().node {
// Non-cacheable nodes are stored too (the return borrows from
// the map) — they are just always refetched above.
self.library_node_cache
.insert(path.to_string(), library_node);
return Ok(self.library_node_cache.get(path));
}
Err(Box::new(RpcClientError::NotFound))
}
/// Creates a child node under a creatable parent and returns it.
///
/// Cache contract: the created node is inserted into
/// `library_node_cache`, and the *parent's* cache entry is evicted —
/// its child listing just changed and would otherwise be served stale
/// when the user ascends back to it.
pub async fn create_library_node(
&mut self,
parent_path: &str,
title: &str,
) -> Result<&LibraryNode, Box<dyn Error>> {
let request = Request::new(CreateLibraryNodeRequest {
parent_path: parent_path.to_string(),
title: title.to_string(),
});
let response = self.client.create_library_node(request).await?;
let Some(node) = response.into_inner().node else {
return Err(Box::new(RpcClientError::NotFound));
};
// The parent's child listing just changed; a cached copy would hide
// the new node when the user ascends back to it.
self.library_node_cache.remove(parent_path);
let path = node.path.clone();
self.library_node_cache.insert(path.clone(), node);
Ok(&self.library_node_cache[&path])
}
/// Renames an editable node and returns it at its (changed) path.
///
/// Cache contract: the old path's entry and the parent's entry are
/// evicted (the parent's child listing changed; the old path is dead),
/// and the renamed node is inserted under its new path.
pub async fn rename_library_node(
&mut self,
path: &str,
new_title: &str,
) -> Result<&LibraryNode, Box<dyn Error>> {
let request = Request::new(RenameLibraryNodeRequest {
path: path.to_string(),
new_title: new_title.to_string(),
});
let response = self.client.rename_library_node(request).await?;
let Some(node) = response.into_inner().node else {
return Err(Box::new(RpcClientError::NotFound));
};
// The old path is dead and the parent's child listing changed;
// cached copies would resurrect the old term.
self.library_node_cache.remove(path);
if let Some(parent) = crabidy_core::parent_path(path) {
self.library_node_cache.remove(parent);
}
let new_path = node.path.clone();
self.library_node_cache.insert(new_path.clone(), node);
Ok(&self.library_node_cache[&new_path])
}
/// Deletes a node and returns the refreshed parent listing.
///
/// Cache contract: the deleted path's entry and the parent's stale entry
/// are evicted, and the returned parent node is inserted fresh.
pub async fn delete_library_node(
&mut self,
path: &str,
) -> Result<&LibraryNode, Box<dyn Error>> {
let request = Request::new(DeleteLibraryNodeRequest {
path: path.to_string(),
});
let response = self.client.delete_library_node(request).await?;
let Some(parent) = response.into_inner().parent else {
return Err(Box::new(RpcClientError::NotFound));
};
// Drop the deleted node and the stale parent listing; the response
// carries the fresh parent to cache instead.
self.library_node_cache.remove(path);
let parent_path = parent.path.clone();
self.library_node_cache.insert(parent_path.clone(), parent);
Ok(&self.library_node_cache[&parent_path])
}
pub async fn append_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
let append_request = Request::new(AppendRequest { paths });
self.client.append(append_request).await?;
Ok(())
}
pub async fn queue_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
let queue_request = Request::new(QueueRequest { paths });
self.client.queue(queue_request).await?;
Ok(())
}
pub async fn insert_tracks(
&mut self,
paths: Vec<String>,
pos: usize,
) -> Result<(), Box<dyn Error>> {
let insert_request = Request::new(InsertRequest {
paths,
position: pos as u32,
});
self.client.insert(insert_request).await?;
Ok(())
}
pub async fn remove_tracks(&mut self, positions: Vec<usize>) -> Result<(), Box<dyn Error>> {
let remove_request = Request::new(RemoveRequest {
positions: positions.iter().map(|p| *p as u32).collect(),
});
self.client.remove(remove_request).await?;
Ok(())
}
pub async fn clear_queue(&mut self, exclude_current: bool) -> Result<(), Box<dyn Error>> {
let clear_queue_request = Request::new(ClearQueueRequest { exclude_current });
self.client.clear_queue(clear_queue_request).await?;
Ok(())
}
pub async fn save_queue(&mut self, name: String) -> Result<(), Box<dyn Error>> {
let save_queue_request = Request::new(SaveQueueRequest { name });
self.client.save_queue(save_queue_request).await?;
Ok(())
}
pub async fn capture_library_node(
&mut self,
path: String,
name: String,
download: bool,
) -> Result<(), Box<dyn Error>> {
let capture_request = Request::new(CaptureLibraryNodeRequest {
path,
name,
download,
});
self.client.capture_library_node(capture_request).await?;
Ok(())
}
pub async fn replace_queue(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
let replace_request = Request::new(ReplaceRequest { paths });
self.client.replace(replace_request).await?;
Ok(())
}
pub async fn next_track(&mut self) -> Result<(), Box<dyn Error>> {
let next_request = Request::new(NextRequest {});
self.client.next(next_request).await?;
Ok(())
}
pub async fn prev_track(&mut self) -> Result<(), Box<dyn Error>> {
let prev_request = Request::new(PrevRequest {});
self.client.prev(prev_request).await?;
Ok(())
}
pub async fn restart_track(&mut self) -> Result<(), Box<dyn Error>> {
let restart_track_request = Request::new(RestartTrackRequest {});
self.client.restart_track(restart_track_request).await?;
Ok(())
}
pub async fn set_current_track(&mut self, pos: usize) -> Result<(), Box<dyn Error>> {
let set_current_request = Request::new(SetCurrentRequest {
position: pos as u32,
});
self.client.set_current(set_current_request).await?;
Ok(())
}
pub async fn toggle_play(&mut self) -> Result<(), Box<dyn Error>> {
let toggle_play_request = Request::new(TogglePlayRequest {});
self.client.toggle_play(toggle_play_request).await?;
Ok(())
}
pub async fn toggle_shuffle(&mut self) -> Result<(), Box<dyn Error>> {
let toggle_shuffle_request = Request::new(ToggleShuffleRequest {});
self.client.toggle_shuffle(toggle_shuffle_request).await?;
Ok(())
}
pub async fn toggle_repeat(&mut self) -> Result<(), Box<dyn Error>> {
let toggle_repeat_request = Request::new(ToggleRepeatRequest {});
self.client.toggle_repeat(toggle_repeat_request).await?;
Ok(())
}
pub async fn change_volume(&mut self, delta: f32) -> Result<(), Box<dyn Error>> {
let change_volume_request = Request::new(ChangeVolumeRequest { delta });
self.client.change_volume(change_volume_request).await?;
Ok(())
}
pub async fn toggle_mute(&mut self) -> Result<(), Box<dyn Error>> {
let toggle_mute_request = Request::new(ToggleMuteRequest {});
self.client.toggle_mute(toggle_mute_request).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn without_credentials_no_authorization_header_is_sent() {
let mut interceptor = AuthInterceptor::new("", "ignored").expect("build");
let request = interceptor.call(Request::new(())).expect("intercept");
assert!(request.metadata().get("authorization").is_none());
}
#[test]
fn credentials_become_a_basic_authorization_header() {
let mut interceptor = AuthInterceptor::new("queue-owner", "secret").expect("build");
let request = interceptor.call(Request::new(())).expect("intercept");
let header = request
.metadata()
.get("authorization")
.expect("header attached")
.to_str()
.expect("ascii");
// base64("queue-owner:secret")
assert_eq!(header, "Basic cXVldWUtb3duZXI6c2VjcmV0");
}
#[test]
fn mutable_provider_listings_are_never_cached() {
// Freshly captured/saved content must show up on the next visit
// (a cached /crabidy hid new saves until a TUI restart).
for path in [
"/crabidy",
"/crabidy/faves",
"/crabidy/current",
"/crabidy/road trip",
"/fs/music",
] {
assert!(!is_cacheable(path), "{path}");
}
// Remote providers keep instant back-navigation…
for path in ["/", "/tidal", "/tidal/artists/1", "/youtube/search/x"] {
assert!(is_cacheable(path), "{path}");
}
// …and prefix look-alikes are not swept up.
assert!(is_cacheable("/fsdy"));
assert!(is_cacheable("/crabidystore"));
}
}