use std::time::Duration;
use reqwest::Client;
use thiserror::Error;
use tracing::{debug, instrument, warn};
use crate::types::{
AmApiRequest, ApiResponse, AutoplayResponse, IsPlayingResponse, NowPlaying,
NowPlayingResponse, PlayItemHrefRequest, PlayItemRequest, PlayUrlRequest, QueueItem,
QueueMoveRequest, QueueRemoveRequest, RatingRequest, RepeatModeResponse, SeekRequest,
ShuffleModeResponse, VolumeRequest, VolumeResponse,
};
pub const DEFAULT_PORT: u16 = 10767;
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(1);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Debug, Error)]
pub enum CiderError {
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("Cider is not running or not reachable")]
NotReachable,
#[error("Invalid API token")]
Unauthorized,
#[error("No track currently playing")]
NothingPlaying,
#[error("API error: {0}")]
Api(String),
}
#[derive(Debug, Clone)]
pub struct CiderClient {
http: Client,
base_url: String,
api_token: Option<String>,
}
impl CiderClient {
#[must_use]
pub fn new() -> Self {
Self::with_port(DEFAULT_PORT)
}
#[must_use]
pub fn with_port(port: u16) -> Self {
let http = Client::builder()
.connect_timeout(CONNECTION_TIMEOUT)
.timeout(REQUEST_TIMEOUT)
.pool_max_idle_per_host(2)
.pool_idle_timeout(Duration::from_secs(10))
.tcp_keepalive(None)
.build()
.expect("Failed to build HTTP client");
Self {
http,
base_url: format!("http://127.0.0.1:{port}"),
api_token: None,
}
}
#[doc(hidden)]
#[must_use]
pub fn with_base_url(base_url: impl Into<String>) -> Self {
let http = Client::builder()
.connect_timeout(CONNECTION_TIMEOUT)
.timeout(REQUEST_TIMEOUT)
.pool_max_idle_per_host(2)
.pool_idle_timeout(Duration::from_secs(10))
.tcp_keepalive(None)
.build()
.expect("Failed to build HTTP client");
Self {
http,
base_url: base_url.into(),
api_token: None,
}
}
#[must_use]
pub fn with_token(mut self, token: impl Into<String>) -> Self {
self.api_token = Some(token.into());
self
}
fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
let url = format!("{}/api/v1/playback{}", self.base_url, path);
let mut req = self.http.request(method, &url);
if let Some(token) = &self.api_token {
req = req.header("apptoken", token);
}
req
}
fn request_raw(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
let url = format!("{}{}", self.base_url, path);
let mut req = self.http.request(method, &url);
if let Some(token) = &self.api_token {
req = req.header("apptoken", token);
}
req
}
#[instrument(skip(self), fields(base_url = %self.base_url))]
pub async fn is_active(&self) -> Result<(), CiderError> {
debug!("Checking Cider connection");
let resp = self
.request(reqwest::Method::GET, "/active")
.send()
.await
.map_err(|e| {
warn!("Connection error: {e:?}");
if e.is_connect() {
CiderError::Api(format!("Connection refused ({e})"))
} else if e.is_timeout() {
CiderError::Api("Connection timed out".to_string())
} else {
CiderError::Api(format!("Network error ({e})"))
}
})?;
debug!("Response status: {}", resp.status());
match resp.status().as_u16() {
200 | 204 => Ok(()),
401 | 403 => Err(CiderError::Unauthorized),
_ => Err(CiderError::Api(format!(
"Unexpected response (HTTP {})",
resp.status().as_u16()
))),
}
}
pub async fn is_playing(&self) -> Result<bool, CiderError> {
let resp: ApiResponse<IsPlayingResponse> = self
.request(reqwest::Method::GET, "/is-playing")
.send()
.await?
.json()
.await?;
Ok(resp.data.is_playing)
}
pub async fn now_playing(&self) -> Result<Option<NowPlaying>, CiderError> {
let resp = self
.request(reqwest::Method::GET, "/now-playing")
.send()
.await?;
if resp.status() == 404 || resp.status() == 204 {
return Ok(None);
}
match resp.json::<ApiResponse<NowPlayingResponse>>().await {
Ok(data) => Ok(Some(data.data.info)),
Err(_) => Ok(None),
}
}
pub async fn play(&self) -> Result<(), CiderError> {
self.request(reqwest::Method::POST, "/play")
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn pause(&self) -> Result<(), CiderError> {
self.request(reqwest::Method::POST, "/pause")
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn play_pause(&self) -> Result<(), CiderError> {
self.request(reqwest::Method::POST, "/playpause")
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn stop(&self) -> Result<(), CiderError> {
self.request(reqwest::Method::POST, "/stop")
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn next(&self) -> Result<(), CiderError> {
self.request(reqwest::Method::POST, "/next")
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn previous(&self) -> Result<(), CiderError> {
self.request(reqwest::Method::POST, "/previous")
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn seek(&self, position_secs: f64) -> Result<(), CiderError> {
self.request(reqwest::Method::POST, "/seek")
.json(&SeekRequest {
position: position_secs,
})
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn seek_ms(&self, position_ms: u64) -> Result<(), CiderError> {
#[allow(clippy::cast_precision_loss)] let secs = position_ms as f64 / 1000.0;
self.seek(secs).await
}
pub async fn play_url(&self, url: &str) -> Result<(), CiderError> {
self.request(reqwest::Method::POST, "/play-url")
.json(&PlayUrlRequest {
url: url.to_string(),
})
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn play_item(&self, item_type: &str, id: &str) -> Result<(), CiderError> {
self.request(reqwest::Method::POST, "/play-item")
.json(&PlayItemRequest {
item_type: item_type.to_string(),
id: id.to_string(),
})
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn play_item_href(&self, href: &str) -> Result<(), CiderError> {
self.request(reqwest::Method::POST, "/play-item-href")
.json(&PlayItemHrefRequest {
href: href.to_string(),
})
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn play_next(&self, item_type: &str, id: &str) -> Result<(), CiderError> {
self.request(reqwest::Method::POST, "/play-next")
.json(&PlayItemRequest {
item_type: item_type.to_string(),
id: id.to_string(),
})
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn play_later(&self, item_type: &str, id: &str) -> Result<(), CiderError> {
self.request(reqwest::Method::POST, "/play-later")
.json(&PlayItemRequest {
item_type: item_type.to_string(),
id: id.to_string(),
})
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn get_queue(&self) -> Result<Vec<QueueItem>, CiderError> {
let resp = self
.request(reqwest::Method::GET, "/queue")
.send()
.await?;
let status = resp.status();
if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::NO_CONTENT {
return Ok(vec![]);
}
let text = resp.text().await?;
match serde_json::from_str::<Vec<QueueItem>>(&text) {
Ok(items) => Ok(items),
Err(_) => Ok(vec![]),
}
}
pub async fn queue_move_to_position(
&self,
start_index: u32,
destination_index: u32,
) -> Result<(), CiderError> {
self.request(reqwest::Method::POST, "/queue/move-to-position")
.json(&QueueMoveRequest {
start_index,
destination_index,
return_queue: None,
})
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn queue_remove_by_index(&self, index: u32) -> Result<(), CiderError> {
self.request(reqwest::Method::POST, "/queue/remove-by-index")
.json(&QueueRemoveRequest { index })
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn clear_queue(&self) -> Result<(), CiderError> {
self.request(reqwest::Method::POST, "/queue/clear-queue")
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn get_volume(&self) -> Result<f32, CiderError> {
let resp: ApiResponse<VolumeResponse> = self
.request(reqwest::Method::GET, "/volume")
.send()
.await?
.json()
.await?;
Ok(resp.data.volume)
}
pub async fn set_volume(&self, volume: f32) -> Result<(), CiderError> {
self.request(reqwest::Method::POST, "/volume")
.json(&VolumeRequest {
volume: volume.clamp(0.0, 1.0),
})
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn add_to_library(&self) -> Result<(), CiderError> {
self.request(reqwest::Method::POST, "/add-to-library")
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn set_rating(&self, rating: i8) -> Result<(), CiderError> {
self.request(reqwest::Method::POST, "/set-rating")
.json(&RatingRequest {
rating: rating.clamp(-1, 1),
})
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn get_repeat_mode(&self) -> Result<u8, CiderError> {
let resp: ApiResponse<RepeatModeResponse> = self
.request(reqwest::Method::GET, "/repeat-mode")
.send()
.await?
.json()
.await?;
Ok(resp.data.value)
}
pub async fn toggle_repeat(&self) -> Result<(), CiderError> {
self.request(reqwest::Method::POST, "/toggle-repeat")
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn get_shuffle_mode(&self) -> Result<u8, CiderError> {
let resp: ApiResponse<ShuffleModeResponse> = self
.request(reqwest::Method::GET, "/shuffle-mode")
.send()
.await?
.json()
.await?;
Ok(resp.data.value)
}
pub async fn toggle_shuffle(&self) -> Result<(), CiderError> {
self.request(reqwest::Method::POST, "/toggle-shuffle")
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn get_autoplay(&self) -> Result<bool, CiderError> {
let resp: ApiResponse<AutoplayResponse> = self
.request(reqwest::Method::GET, "/autoplay")
.send()
.await?
.json()
.await?;
Ok(resp.data.value)
}
pub async fn toggle_autoplay(&self) -> Result<(), CiderError> {
self.request(reqwest::Method::POST, "/toggle-autoplay")
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn amapi_run_v3(&self, path: &str) -> Result<serde_json::Value, CiderError> {
let resp = self
.request_raw(reqwest::Method::POST, "/api/v1/amapi/run-v3")
.json(&AmApiRequest {
path: path.to_string(),
})
.send()
.await?
.error_for_status()?;
resp.json().await.map_err(CiderError::from)
}
}
impl Default for CiderClient {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_client() {
let client = CiderClient::new();
assert_eq!(client.base_url, "http://127.0.0.1:10767");
assert!(client.api_token.is_none());
}
#[test]
fn client_with_token() {
let client = CiderClient::new().with_token("test-token");
assert_eq!(client.api_token, Some("test-token".to_string()));
}
#[test]
fn client_custom_port() {
let client = CiderClient::with_port(9999);
assert_eq!(client.base_url, "http://127.0.0.1:9999");
}
#[test]
fn client_is_clone() {
let a = CiderClient::new();
let b = a.clone();
assert_eq!(a.base_url, b.base_url);
}
#[test]
fn default_trait_same_as_new() {
let a = CiderClient::new();
let b = CiderClient::default();
assert_eq!(a.base_url, b.base_url);
assert_eq!(a.api_token, b.api_token);
}
#[test]
fn with_base_url_sets_arbitrary_url() {
let client = CiderClient::with_base_url("http://example.com:1234");
assert_eq!(client.base_url, "http://example.com:1234");
assert!(client.api_token.is_none());
}
#[test]
fn with_token_is_chainable() {
let client = CiderClient::with_port(8080).with_token("tok");
assert_eq!(client.base_url, "http://127.0.0.1:8080");
assert_eq!(client.api_token, Some("tok".to_string()));
}
#[test]
fn with_token_accepts_owned_string() {
let token = String::from("owned-token");
let client = CiderClient::new().with_token(token);
assert_eq!(client.api_token, Some("owned-token".to_string()));
}
#[test]
fn request_builds_correct_url() {
let client = CiderClient::with_port(9999);
let req = client.request(reqwest::Method::GET, "/active");
let built = req.build().unwrap();
assert_eq!(
built.url().as_str(),
"http://127.0.0.1:9999/api/v1/playback/active"
);
}
#[test]
fn request_raw_builds_correct_url() {
let client = CiderClient::with_port(9999);
let req = client.request_raw(reqwest::Method::POST, "/api/v1/amapi/run-v3");
let built = req.build().unwrap();
assert_eq!(
built.url().as_str(),
"http://127.0.0.1:9999/api/v1/amapi/run-v3"
);
}
#[test]
fn request_includes_token_header() {
let client = CiderClient::new().with_token("my-secret");
let req = client.request(reqwest::Method::GET, "/active");
let built = req.build().unwrap();
assert_eq!(built.headers().get("apptoken").unwrap(), "my-secret");
}
#[test]
fn request_omits_token_header_when_none() {
let client = CiderClient::new();
let req = client.request(reqwest::Method::GET, "/active");
let built = req.build().unwrap();
assert!(built.headers().get("apptoken").is_none());
}
#[test]
fn request_raw_includes_token_header() {
let client = CiderClient::new().with_token("secret");
let req = client.request_raw(reqwest::Method::POST, "/api/v1/amapi/run-v3");
let built = req.build().unwrap();
assert_eq!(built.headers().get("apptoken").unwrap(), "secret");
}
}