Skip to main content

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}