cider_api/client.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Async HTTP client for the Cider REST API.
6
7use std::time::Duration;
8
9use reqwest::Client;
10use thiserror::Error;
11use tracing::{debug, instrument, warn};
12
13use crate::types::{
14 AmApiRequest, ApiResponse, AutoplayResponse, IsPlayingResponse, NowPlaying,
15 NowPlayingResponse, PlayItemHrefRequest, PlayItemRequest, PlayUrlRequest, QueueItem,
16 QueueMoveRequest, QueueRemoveRequest, RatingRequest, RepeatModeResponse, SeekRequest,
17 ShuffleModeResponse, VolumeRequest, VolumeResponse,
18};
19
20/// Default Cider RPC port.
21pub const DEFAULT_PORT: u16 = 10767;
22
23/// Connection timeout — short because the server is localhost.
24const CONNECTION_TIMEOUT: Duration = Duration::from_secs(1);
25
26/// Per-request timeout.
27const REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
28
29/// Errors returned by [`CiderClient`] methods.
30///
31/// # Examples
32///
33/// ```no_run
34/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
35/// use cider_api::{CiderClient, CiderError};
36///
37/// let client = CiderClient::new();
38/// match client.is_active().await {
39/// Ok(()) => println!("Cider is running"),
40/// Err(CiderError::Unauthorized) => println!("Bad API token"),
41/// Err(CiderError::Http(e)) if e.is_connect() => println!("Cider not running"),
42/// Err(e) => println!("Error: {e}"),
43/// }
44/// # Ok(())
45/// # }
46/// ```
47#[derive(Debug, Error)]
48pub enum CiderError {
49 /// An HTTP-level error from [`reqwest`].
50 #[error("HTTP request failed: {0}")]
51 Http(#[from] reqwest::Error),
52
53 /// Cider is not running or the port is unreachable.
54 #[error("Cider is not running or not reachable")]
55 NotReachable,
56
57 /// The API token was rejected (HTTP 401/403).
58 #[error("Invalid API token")]
59 Unauthorized,
60
61 /// No track is currently loaded.
62 #[error("No track currently playing")]
63 NothingPlaying,
64
65 /// Catch-all for unexpected API responses.
66 #[error("API error: {0}")]
67 Api(String),
68}
69
70/// Async client for the [Cider](https://cider.sh) music player REST API.
71///
72/// Communicates with Cider's local HTTP server (default `http://127.0.0.1:10767`)
73/// to control playback, manage the queue, and query track information.
74///
75/// # Construction
76///
77/// ```
78/// use cider_api::CiderClient;
79///
80/// // Default (localhost:10767, no auth)
81/// let client = CiderClient::new();
82///
83/// // Custom port
84/// let client = CiderClient::with_port(9999);
85///
86/// // With authentication
87/// let client = CiderClient::new().with_token("my-token");
88/// ```
89///
90/// The client is cheaply [`Clone`]able — it shares an inner connection pool.
91///
92/// # Errors
93///
94/// All async methods return `Result<_, CiderError>`. Common error cases:
95///
96/// - [`CiderError::Http`] — network or connection failure.
97/// - [`CiderError::Unauthorized`] — invalid API token (HTTP 401/403).
98/// - [`CiderError::Api`] — unexpected response from Cider.
99#[derive(Debug, Clone)]
100pub struct CiderClient {
101 http: Client,
102 base_url: String,
103 api_token: Option<String>,
104}
105
106impl CiderClient {
107 /// Create a new client targeting `http://127.0.0.1:10767`.
108 #[must_use]
109 pub fn new() -> Self {
110 Self::with_port(DEFAULT_PORT)
111 }
112
113 /// Create a new client targeting `http://127.0.0.1:{port}`.
114 ///
115 /// # Panics
116 ///
117 /// Panics if the underlying HTTP client cannot be constructed (only
118 /// possible if TLS initialisation fails at the OS level).
119 #[must_use]
120 pub fn with_port(port: u16) -> Self {
121 let http = Client::builder()
122 .connect_timeout(CONNECTION_TIMEOUT)
123 .timeout(REQUEST_TIMEOUT)
124 .pool_max_idle_per_host(2)
125 .pool_idle_timeout(Duration::from_secs(10))
126 .tcp_keepalive(None)
127 .build()
128 .expect("Failed to build HTTP client");
129
130 Self {
131 http,
132 base_url: format!("http://127.0.0.1:{port}"),
133 api_token: None,
134 }
135 }
136
137 /// Create a client targeting an arbitrary base URL.
138 ///
139 /// This is intended for testing (e.g. pointing at a mock server).
140 #[doc(hidden)]
141 #[must_use]
142 pub fn with_base_url(base_url: impl Into<String>) -> Self {
143 let http = Client::builder()
144 .connect_timeout(CONNECTION_TIMEOUT)
145 .timeout(REQUEST_TIMEOUT)
146 .pool_max_idle_per_host(2)
147 .pool_idle_timeout(Duration::from_secs(10))
148 .tcp_keepalive(None)
149 .build()
150 .expect("Failed to build HTTP client");
151
152 Self {
153 http,
154 base_url: base_url.into(),
155 api_token: None,
156 }
157 }
158
159 /// Attach an API token for authentication.
160 ///
161 /// The token is sent in the `apptoken` header on every request.
162 /// Generate one in Cider under **Settings > Connectivity > Manage External
163 /// Application Access**.
164 #[must_use]
165 pub fn with_token(mut self, token: impl Into<String>) -> Self {
166 self.api_token = Some(token.into());
167 self
168 }
169
170 // ── Internal helpers ─────────────────────────────────────────────────
171
172 /// Build a request under `/api/v1/playback`.
173 fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
174 let url = format!("{}/api/v1/playback{}", self.base_url, path);
175 let mut req = self.http.request(method, &url);
176 if let Some(token) = &self.api_token {
177 req = req.header("apptoken", token);
178 }
179 req
180 }
181
182 /// Build a request under an arbitrary API path.
183 fn request_raw(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
184 let url = format!("{}{}", self.base_url, path);
185 let mut req = self.http.request(method, &url);
186 if let Some(token) = &self.api_token {
187 req = req.header("apptoken", token);
188 }
189 req
190 }
191
192 // ── Status ───────────────────────────────────────────────────────────
193
194 /// Check that Cider is running and the RPC server is reachable.
195 ///
196 /// Sends `GET /active` — Cider responds with `204 No Content` if alive.
197 ///
198 /// # Errors
199 ///
200 /// - [`CiderError::Unauthorized`] if the token is wrong.
201 /// - [`CiderError::Api`] if the connection is refused or times out.
202 #[instrument(skip(self), fields(base_url = %self.base_url))]
203 pub async fn is_active(&self) -> Result<(), CiderError> {
204 debug!("Checking Cider connection");
205
206 let resp = self
207 .request(reqwest::Method::GET, "/active")
208 .send()
209 .await
210 .map_err(|e| {
211 warn!("Connection error: {e:?}");
212 if e.is_connect() {
213 CiderError::Api(format!("Connection refused ({e})"))
214 } else if e.is_timeout() {
215 CiderError::Api("Connection timed out".to_string())
216 } else {
217 CiderError::Api(format!("Network error ({e})"))
218 }
219 })?;
220
221 debug!("Response status: {}", resp.status());
222
223 match resp.status().as_u16() {
224 200 | 204 => Ok(()),
225 401 | 403 => Err(CiderError::Unauthorized),
226 _ => Err(CiderError::Api(format!(
227 "Unexpected response (HTTP {})",
228 resp.status().as_u16()
229 ))),
230 }
231 }
232
233 /// Check whether music is currently playing.
234 ///
235 /// Sends `GET /is-playing`.
236 ///
237 /// # Errors
238 ///
239 /// Returns [`CiderError`] if the request fails or the response cannot be parsed.
240 pub async fn is_playing(&self) -> Result<bool, CiderError> {
241 let resp: ApiResponse<IsPlayingResponse> = self
242 .request(reqwest::Method::GET, "/is-playing")
243 .send()
244 .await?
245 .json()
246 .await?;
247
248 Ok(resp.data.is_playing)
249 }
250
251 /// Get the currently playing track.
252 ///
253 /// Returns `None` if nothing is loaded. The returned [`NowPlaying`] includes
254 /// both Apple Music catalog metadata and live playback state
255 /// (`current_playback_time`, `remaining_time`, etc.).
256 ///
257 /// Sends `GET /now-playing`.
258 ///
259 /// # Errors
260 ///
261 /// Returns [`CiderError`] on network failure. Returns `Ok(None)` (not an
262 /// error) if nothing is playing or the response cannot be parsed.
263 pub async fn now_playing(&self) -> Result<Option<NowPlaying>, CiderError> {
264 let resp = self
265 .request(reqwest::Method::GET, "/now-playing")
266 .send()
267 .await?;
268
269 if resp.status() == 404 || resp.status() == 204 {
270 return Ok(None);
271 }
272
273 match resp.json::<ApiResponse<NowPlayingResponse>>().await {
274 Ok(data) => Ok(Some(data.data.info)),
275 Err(_) => Ok(None),
276 }
277 }
278
279 // ── Playback control ─────────────────────────────────────────────────
280
281 /// Resume playback.
282 ///
283 /// If nothing is loaded, the behaviour set under
284 /// **Settings > Play Button on Stopped Action** takes effect.
285 ///
286 /// # Errors
287 ///
288 /// Returns [`CiderError`] if the request fails or the server rejects it.
289 pub async fn play(&self) -> Result<(), CiderError> {
290 self.request(reqwest::Method::POST, "/play")
291 .send()
292 .await?
293 .error_for_status()?;
294 Ok(())
295 }
296
297 /// Pause the current track. No-op if already paused or nothing is playing.
298 ///
299 /// # Errors
300 ///
301 /// Returns [`CiderError`] if the request fails or the server rejects it.
302 pub async fn pause(&self) -> Result<(), CiderError> {
303 self.request(reqwest::Method::POST, "/pause")
304 .send()
305 .await?
306 .error_for_status()?;
307 Ok(())
308 }
309
310 /// Toggle between playing and paused.
311 ///
312 /// # Errors
313 ///
314 /// Returns [`CiderError`] if the request fails or the server rejects it.
315 pub async fn play_pause(&self) -> Result<(), CiderError> {
316 self.request(reqwest::Method::POST, "/playpause")
317 .send()
318 .await?
319 .error_for_status()?;
320 Ok(())
321 }
322
323 /// Stop playback and unload the current track. Queue items are kept.
324 ///
325 /// # Errors
326 ///
327 /// Returns [`CiderError`] if the request fails or the server rejects it.
328 pub async fn stop(&self) -> Result<(), CiderError> {
329 self.request(reqwest::Method::POST, "/stop")
330 .send()
331 .await?
332 .error_for_status()?;
333 Ok(())
334 }
335
336 /// Skip to the next track in the queue.
337 ///
338 /// Respects autoplay status if the queue is empty.
339 ///
340 /// # Errors
341 ///
342 /// Returns [`CiderError`] if the request fails or the server rejects it.
343 pub async fn next(&self) -> Result<(), CiderError> {
344 self.request(reqwest::Method::POST, "/next")
345 .send()
346 .await?
347 .error_for_status()?;
348 Ok(())
349 }
350
351 /// Go back to the previously played track (from playback history).
352 ///
353 /// # Errors
354 ///
355 /// Returns [`CiderError`] if the request fails or the server rejects it.
356 pub async fn previous(&self) -> Result<(), CiderError> {
357 self.request(reqwest::Method::POST, "/previous")
358 .send()
359 .await?
360 .error_for_status()?;
361 Ok(())
362 }
363
364 /// Seek to a position in the current track.
365 ///
366 /// # Arguments
367 ///
368 /// * `position_secs` — target offset in **seconds** (e.g. `30.0`).
369 ///
370 /// # Errors
371 ///
372 /// Returns [`CiderError`] if the request fails or the server rejects it.
373 pub async fn seek(&self, position_secs: f64) -> Result<(), CiderError> {
374 self.request(reqwest::Method::POST, "/seek")
375 .json(&SeekRequest {
376 position: position_secs,
377 })
378 .send()
379 .await?
380 .error_for_status()?;
381 Ok(())
382 }
383
384 /// Convenience wrapper for [`seek`](Self::seek) that accepts milliseconds.
385 ///
386 /// # Errors
387 ///
388 /// Returns [`CiderError`] if the request fails or the server rejects it.
389 pub async fn seek_ms(&self, position_ms: u64) -> Result<(), CiderError> {
390 #[allow(clippy::cast_precision_loss)] // ms precision loss only above ~143 million years
391 let secs = position_ms as f64 / 1000.0;
392 self.seek(secs).await
393 }
394
395 // ── Play items ───────────────────────────────────────────────────────
396
397 /// Start playback of an Apple Music URL.
398 ///
399 /// The URL can be obtained from **Share > Apple Music** in Cider or the
400 /// Apple Music web player.
401 ///
402 /// # Arguments
403 ///
404 /// * `url` — e.g. `"https://music.apple.com/ca/album/…/1719860281"`
405 ///
406 /// # Errors
407 ///
408 /// Returns [`CiderError`] if the request fails or the server rejects it.
409 pub async fn play_url(&self, url: &str) -> Result<(), CiderError> {
410 self.request(reqwest::Method::POST, "/play-url")
411 .json(&PlayUrlRequest {
412 url: url.to_string(),
413 })
414 .send()
415 .await?
416 .error_for_status()?;
417 Ok(())
418 }
419
420 /// Start playback of an item by Apple Music type and catalog ID.
421 ///
422 /// # Arguments
423 ///
424 /// * `item_type` — Apple Music type: `"songs"`, `"albums"`, `"playlists"`, etc.
425 /// * `id` — catalog ID as a **string** (e.g. `"1719861213"`).
426 ///
427 /// # Errors
428 ///
429 /// Returns [`CiderError`] if the request fails or the server rejects it.
430 pub async fn play_item(&self, item_type: &str, id: &str) -> Result<(), CiderError> {
431 self.request(reqwest::Method::POST, "/play-item")
432 .json(&PlayItemRequest {
433 item_type: item_type.to_string(),
434 id: id.to_string(),
435 })
436 .send()
437 .await?
438 .error_for_status()?;
439 Ok(())
440 }
441
442 /// Start playback of an item by its Apple Music API href.
443 ///
444 /// # Arguments
445 ///
446 /// * `href` — API path, e.g. `"/v1/catalog/ca/songs/1719861213"`.
447 ///
448 /// # Errors
449 ///
450 /// Returns [`CiderError`] if the request fails or the server rejects it.
451 pub async fn play_item_href(&self, href: &str) -> Result<(), CiderError> {
452 self.request(reqwest::Method::POST, "/play-item-href")
453 .json(&PlayItemHrefRequest {
454 href: href.to_string(),
455 })
456 .send()
457 .await?
458 .error_for_status()?;
459 Ok(())
460 }
461
462 /// Add an item to the **start** of the queue (plays next).
463 ///
464 /// # Arguments
465 ///
466 /// * `item_type` — `"songs"`, `"albums"`, etc.
467 /// * `id` — catalog ID as a string.
468 ///
469 /// # Errors
470 ///
471 /// Returns [`CiderError`] if the request fails or the server rejects it.
472 pub async fn play_next(&self, item_type: &str, id: &str) -> Result<(), CiderError> {
473 self.request(reqwest::Method::POST, "/play-next")
474 .json(&PlayItemRequest {
475 item_type: item_type.to_string(),
476 id: id.to_string(),
477 })
478 .send()
479 .await?
480 .error_for_status()?;
481 Ok(())
482 }
483
484 /// Add an item to the **end** of the queue (plays last).
485 ///
486 /// # Arguments
487 ///
488 /// * `item_type` — `"songs"`, `"albums"`, etc.
489 /// * `id` — catalog ID as a string.
490 ///
491 /// # Errors
492 ///
493 /// Returns [`CiderError`] if the request fails or the server rejects it.
494 pub async fn play_later(&self, item_type: &str, id: &str) -> Result<(), CiderError> {
495 self.request(reqwest::Method::POST, "/play-later")
496 .json(&PlayItemRequest {
497 item_type: item_type.to_string(),
498 id: id.to_string(),
499 })
500 .send()
501 .await?
502 .error_for_status()?;
503 Ok(())
504 }
505
506 // ── Queue ────────────────────────────────────────────────────────────
507
508 /// Get the current playback queue.
509 ///
510 /// Returns a [`Vec<QueueItem>`] that includes history items, the currently
511 /// playing track, and upcoming items. Use [`QueueItem::is_current`] to
512 /// find the active track.
513 ///
514 /// Returns an empty `Vec` if the queue is empty or the response format is
515 /// unexpected.
516 ///
517 /// # Errors
518 ///
519 /// Returns [`CiderError`] on network failure. Returns `Ok(vec![])` (not an
520 /// error) if the queue is empty or the format is unrecognised.
521 pub async fn get_queue(&self) -> Result<Vec<QueueItem>, CiderError> {
522 let resp = self
523 .request(reqwest::Method::GET, "/queue")
524 .send()
525 .await?;
526
527 let status = resp.status();
528 if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::NO_CONTENT {
529 return Ok(vec![]);
530 }
531
532 let text = resp.text().await?;
533 match serde_json::from_str::<Vec<QueueItem>>(&text) {
534 Ok(items) => Ok(items),
535 Err(_) => Ok(vec![]),
536 }
537 }
538
539 /// Move a queue item from one position to another.
540 ///
541 /// Both indices are **1-based**. The queue includes history items, so the
542 /// first visible "Up Next" item may not be at index 1.
543 ///
544 /// # Errors
545 ///
546 /// Returns [`CiderError`] if the request fails or the server rejects it.
547 pub async fn queue_move_to_position(
548 &self,
549 start_index: u32,
550 destination_index: u32,
551 ) -> Result<(), CiderError> {
552 self.request(reqwest::Method::POST, "/queue/move-to-position")
553 .json(&QueueMoveRequest {
554 start_index,
555 destination_index,
556 return_queue: None,
557 })
558 .send()
559 .await?
560 .error_for_status()?;
561 Ok(())
562 }
563
564 /// Remove a queue item by its **1-based** index.
565 ///
566 /// # Errors
567 ///
568 /// Returns [`CiderError`] if the request fails or the server rejects it.
569 pub async fn queue_remove_by_index(&self, index: u32) -> Result<(), CiderError> {
570 self.request(reqwest::Method::POST, "/queue/remove-by-index")
571 .json(&QueueRemoveRequest { index })
572 .send()
573 .await?
574 .error_for_status()?;
575 Ok(())
576 }
577
578 /// Clear all items from the queue.
579 ///
580 /// # Errors
581 ///
582 /// Returns [`CiderError`] if the request fails or the server rejects it.
583 pub async fn clear_queue(&self) -> Result<(), CiderError> {
584 self.request(reqwest::Method::POST, "/queue/clear-queue")
585 .send()
586 .await?
587 .error_for_status()?;
588 Ok(())
589 }
590
591 // ── Volume ───────────────────────────────────────────────────────────
592
593 /// Get the current volume (`0.0` = muted, `1.0` = full).
594 ///
595 /// # Errors
596 ///
597 /// Returns [`CiderError`] if the request fails or the response cannot be parsed.
598 pub async fn get_volume(&self) -> Result<f32, CiderError> {
599 let resp: ApiResponse<VolumeResponse> = self
600 .request(reqwest::Method::GET, "/volume")
601 .send()
602 .await?
603 .json()
604 .await?;
605
606 Ok(resp.data.volume)
607 }
608
609 /// Set the volume. Values are clamped to `0.0..=1.0`.
610 ///
611 /// # Errors
612 ///
613 /// Returns [`CiderError`] if the request fails or the server rejects it.
614 pub async fn set_volume(&self, volume: f32) -> Result<(), CiderError> {
615 self.request(reqwest::Method::POST, "/volume")
616 .json(&VolumeRequest {
617 volume: volume.clamp(0.0, 1.0),
618 })
619 .send()
620 .await?
621 .error_for_status()?;
622 Ok(())
623 }
624
625 // ── Library / ratings ────────────────────────────────────────────────
626
627 /// Add the currently playing track to the user's library.
628 ///
629 /// No-op if the track is already in the library.
630 ///
631 /// # Errors
632 ///
633 /// Returns [`CiderError`] if the request fails or the server rejects it.
634 pub async fn add_to_library(&self) -> Result<(), CiderError> {
635 self.request(reqwest::Method::POST, "/add-to-library")
636 .send()
637 .await?
638 .error_for_status()?;
639 Ok(())
640 }
641
642 /// Rate the currently playing track.
643 ///
644 /// * `-1` — dislike
645 /// * `0` — remove rating
646 /// * `1` — like
647 ///
648 /// The value is clamped to `-1..=1`.
649 ///
650 /// # Errors
651 ///
652 /// Returns [`CiderError`] if the request fails or the server rejects it.
653 pub async fn set_rating(&self, rating: i8) -> Result<(), CiderError> {
654 self.request(reqwest::Method::POST, "/set-rating")
655 .json(&RatingRequest {
656 rating: rating.clamp(-1, 1),
657 })
658 .send()
659 .await?
660 .error_for_status()?;
661 Ok(())
662 }
663
664 // ── Repeat / shuffle / autoplay ──────────────────────────────────────
665
666 /// Get the current repeat mode.
667 ///
668 /// * `0` — off
669 /// * `1` — repeat this song
670 /// * `2` — repeat all
671 ///
672 /// # Errors
673 ///
674 /// Returns [`CiderError`] if the request fails or the response cannot be parsed.
675 pub async fn get_repeat_mode(&self) -> Result<u8, CiderError> {
676 let resp: ApiResponse<RepeatModeResponse> = self
677 .request(reqwest::Method::GET, "/repeat-mode")
678 .send()
679 .await?
680 .json()
681 .await?;
682
683 Ok(resp.data.value)
684 }
685
686 /// Cycle repeat mode: **repeat one > repeat all > off**.
687 ///
688 /// # Errors
689 ///
690 /// Returns [`CiderError`] if the request fails or the server rejects it.
691 pub async fn toggle_repeat(&self) -> Result<(), CiderError> {
692 self.request(reqwest::Method::POST, "/toggle-repeat")
693 .send()
694 .await?
695 .error_for_status()?;
696 Ok(())
697 }
698
699 /// Get the current shuffle mode (`0` = off, `1` = on).
700 ///
701 /// # Errors
702 ///
703 /// Returns [`CiderError`] if the request fails or the response cannot be parsed.
704 pub async fn get_shuffle_mode(&self) -> Result<u8, CiderError> {
705 let resp: ApiResponse<ShuffleModeResponse> = self
706 .request(reqwest::Method::GET, "/shuffle-mode")
707 .send()
708 .await?
709 .json()
710 .await?;
711
712 Ok(resp.data.value)
713 }
714
715 /// Toggle shuffle on/off.
716 ///
717 /// # Errors
718 ///
719 /// Returns [`CiderError`] if the request fails or the server rejects it.
720 pub async fn toggle_shuffle(&self) -> Result<(), CiderError> {
721 self.request(reqwest::Method::POST, "/toggle-shuffle")
722 .send()
723 .await?
724 .error_for_status()?;
725 Ok(())
726 }
727
728 /// Get the current autoplay status (`true` = on).
729 ///
730 /// # Errors
731 ///
732 /// Returns [`CiderError`] if the request fails or the response cannot be parsed.
733 pub async fn get_autoplay(&self) -> Result<bool, CiderError> {
734 let resp: ApiResponse<AutoplayResponse> = self
735 .request(reqwest::Method::GET, "/autoplay")
736 .send()
737 .await?
738 .json()
739 .await?;
740
741 Ok(resp.data.value)
742 }
743
744 /// Toggle autoplay on/off.
745 ///
746 /// # Errors
747 ///
748 /// Returns [`CiderError`] if the request fails or the server rejects it.
749 pub async fn toggle_autoplay(&self) -> Result<(), CiderError> {
750 self.request(reqwest::Method::POST, "/toggle-autoplay")
751 .send()
752 .await?
753 .error_for_status()?;
754 Ok(())
755 }
756
757 // ── Apple Music API passthrough ──────────────────────────────────────
758
759 /// Execute a raw Apple Music API request via Cider's passthrough.
760 ///
761 /// Sends `POST /api/v1/amapi/run-v3` with the given `path`, and returns
762 /// the raw JSON response from Apple Music.
763 ///
764 /// # Arguments
765 ///
766 /// * `path` — Apple Music API path, e.g. `"/v1/me/library/songs"` or
767 /// `"/v1/catalog/us/search?term=flume&types=songs"`.
768 ///
769 /// # Errors
770 ///
771 /// Returns [`CiderError`] if the request fails or the response cannot be parsed.
772 pub async fn amapi_run_v3(&self, path: &str) -> Result<serde_json::Value, CiderError> {
773 let resp = self
774 .request_raw(reqwest::Method::POST, "/api/v1/amapi/run-v3")
775 .json(&AmApiRequest {
776 path: path.to_string(),
777 })
778 .send()
779 .await?
780 .error_for_status()?;
781
782 resp.json().await.map_err(CiderError::from)
783 }
784}
785
786impl Default for CiderClient {
787 fn default() -> Self {
788 Self::new()
789 }
790}
791
792#[cfg(test)]
793mod tests {
794 use super::*;
795
796 #[test]
797 fn default_client() {
798 let client = CiderClient::new();
799 assert_eq!(client.base_url, "http://127.0.0.1:10767");
800 assert!(client.api_token.is_none());
801 }
802
803 #[test]
804 fn client_with_token() {
805 let client = CiderClient::new().with_token("test-token");
806 assert_eq!(client.api_token, Some("test-token".to_string()));
807 }
808
809 #[test]
810 fn client_custom_port() {
811 let client = CiderClient::with_port(9999);
812 assert_eq!(client.base_url, "http://127.0.0.1:9999");
813 }
814
815 #[test]
816 fn client_is_clone() {
817 let a = CiderClient::new();
818 let b = a.clone();
819 assert_eq!(a.base_url, b.base_url);
820 }
821
822 #[test]
823 fn default_trait_same_as_new() {
824 let a = CiderClient::new();
825 let b = CiderClient::default();
826 assert_eq!(a.base_url, b.base_url);
827 assert_eq!(a.api_token, b.api_token);
828 }
829
830 #[test]
831 fn with_base_url_sets_arbitrary_url() {
832 let client = CiderClient::with_base_url("http://example.com:1234");
833 assert_eq!(client.base_url, "http://example.com:1234");
834 assert!(client.api_token.is_none());
835 }
836
837 #[test]
838 fn with_token_is_chainable() {
839 let client = CiderClient::with_port(8080).with_token("tok");
840 assert_eq!(client.base_url, "http://127.0.0.1:8080");
841 assert_eq!(client.api_token, Some("tok".to_string()));
842 }
843
844 #[test]
845 fn with_token_accepts_owned_string() {
846 let token = String::from("owned-token");
847 let client = CiderClient::new().with_token(token);
848 assert_eq!(client.api_token, Some("owned-token".to_string()));
849 }
850
851 #[test]
852 fn request_builds_correct_url() {
853 let client = CiderClient::with_port(9999);
854 let req = client.request(reqwest::Method::GET, "/active");
855 let built = req.build().unwrap();
856 assert_eq!(
857 built.url().as_str(),
858 "http://127.0.0.1:9999/api/v1/playback/active"
859 );
860 }
861
862 #[test]
863 fn request_raw_builds_correct_url() {
864 let client = CiderClient::with_port(9999);
865 let req = client.request_raw(reqwest::Method::POST, "/api/v1/amapi/run-v3");
866 let built = req.build().unwrap();
867 assert_eq!(
868 built.url().as_str(),
869 "http://127.0.0.1:9999/api/v1/amapi/run-v3"
870 );
871 }
872
873 #[test]
874 fn request_includes_token_header() {
875 let client = CiderClient::new().with_token("my-secret");
876 let req = client.request(reqwest::Method::GET, "/active");
877 let built = req.build().unwrap();
878 assert_eq!(built.headers().get("apptoken").unwrap(), "my-secret");
879 }
880
881 #[test]
882 fn request_omits_token_header_when_none() {
883 let client = CiderClient::new();
884 let req = client.request(reqwest::Method::GET, "/active");
885 let built = req.build().unwrap();
886 assert!(built.headers().get("apptoken").is_none());
887 }
888
889 #[test]
890 fn request_raw_includes_token_header() {
891 let client = CiderClient::new().with_token("secret");
892 let req = client.request_raw(reqwest::Method::POST, "/api/v1/amapi/run-v3");
893 let built = req.build().unwrap();
894 assert_eq!(built.headers().get("apptoken").unwrap(), "secret");
895 }
896}