Skip to main content

koan_core/remote/
client.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::OnceLock;
4
5use serde::Deserialize;
6use thiserror::Error;
7
8use super::download::{self, DownloadError};
9
10const API_VERSION: &str = "1.16.1";
11const CLIENT_NAME: &str = "koan";
12const PLAYBACK_REPORT_EXTENSION: &str = "playbackReport";
13
14#[derive(Debug, Error)]
15pub enum SubsonicError {
16    #[error("http error: {0}")]
17    Http(reqwest::Error),
18    #[error("api error: {code} — {message}")]
19    Api { code: i32, message: String },
20    #[error("unexpected response format")]
21    BadResponse,
22    #[error("io error: {0}")]
23    Io(#[from] std::io::Error),
24    #[error("download error: {0}")]
25    Download(#[from] DownloadError),
26    #[error("entropy source unavailable: {0}")]
27    Entropy(#[from] getrandom::Error),
28}
29
30/// Without the URL: every request is signed with the account's credentials in
31/// its query, and the error's message would carry them to wherever it is shown
32/// or logged.
33impl From<reqwest::Error> for SubsonicError {
34    fn from(e: reqwest::Error) -> Self {
35        Self::Http(e.without_url())
36    }
37}
38
39/// A Subsonic server and the credentials that sign requests to it.
40///
41/// Kept separate from `SubsonicClient` because constructing that builds two
42/// blocking `reqwest` clients, each carrying its own runtime — doing so from
43/// inside a tokio runtime panics. A caller that only needs a signed URL, such
44/// as koan's own Subsonic proxy, holds this instead.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct SubsonicAuth {
47    pub base_url: String,
48    /// Who the credential signs in as. Not sent with an API key, which names
49    /// its account itself, but still what the account is known by locally.
50    pub username: String,
51    pub credential: Credential,
52}
53
54/// What signs each request.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum Credential {
57    Password(String),
58    /// OpenSubsonic's `apiKeyAuthentication`. What redeeming a koan invite
59    /// gives.
60    ApiKey(String),
61}
62
63impl SubsonicAuth {
64    pub fn new(base_url: &str, username: &str, password: &str) -> Self {
65        Self::with(
66            base_url,
67            username,
68            Credential::Password(password.to_string()),
69        )
70    }
71
72    pub fn with(base_url: &str, username: &str, credential: Credential) -> Self {
73        Self {
74            base_url: base_url.trim_end_matches('/').to_string(),
75            username: username.to_string(),
76            credential,
77        }
78    }
79
80    /// Build auth query params: `apiKey`, or u then p (HTTPS) or t and s; then
81    /// v, c, f.
82    ///
83    /// Over HTTPS a password goes as `p=enc:<hex>`: a koan server checks
84    /// accounts against an argon2 hash, which token auth cannot be checked
85    /// against. Over plain HTTP that would expose the password, so the salted
86    /// token is sent instead, as every Subsonic server accepts.
87    fn params(&self) -> Result<HashMap<String, String>, SubsonicError> {
88        let mut params = HashMap::new();
89        match &self.credential {
90            Credential::ApiKey(key) => {
91                params.insert("apiKey".into(), key.clone());
92            }
93            Credential::Password(password) => {
94                params.insert("u".into(), self.username.clone());
95                if self.base_url.starts_with("https://") {
96                    let hex: String = password.bytes().map(|b| format!("{b:02x}")).collect();
97                    params.insert("p".into(), format!("enc:{hex}"));
98                } else {
99                    let salt = random_salt()?;
100                    let token = format!("{:x}", md5::compute(format!("{password}{salt}")));
101                    params.insert("t".into(), token);
102                    params.insert("s".into(), salt);
103                }
104            }
105        }
106        params.insert("v".into(), API_VERSION.into());
107        params.insert("c".into(), CLIENT_NAME.into());
108        params.insert("f".into(), "json".into());
109        Ok(params)
110    }
111
112    /// The auth params as a query string. Every value is URL-safe as built:
113    /// API keys are base64url.
114    pub fn query(&self) -> Result<String, SubsonicError> {
115        Ok(self
116            .params()?
117            .iter()
118            .map(|(k, v)| format!("{}={}", k, v))
119            .collect::<Vec<_>>()
120            .join("&"))
121    }
122
123    /// Build the streaming URL for a track (doesn't make a request).
124    pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
125        Ok(format!(
126            "{}/rest/stream?id={}&{}",
127            self.base_url,
128            track_id,
129            self.query()?
130        ))
131    }
132}
133
134/// Subsonic/Navidrome API client.
135///
136/// Holds two HTTP clients with different timeout semantics: `http` bounds a
137/// whole JSON request, which is right for small API responses read in one go;
138/// `downloader` bounds only connect and per-read stalls, so a large track on a
139/// slow link is never cut off for taking too long overall.
140pub struct SubsonicClient {
141    auth: SubsonicAuth,
142    http: reqwest::blocking::Client,
143    downloader: reqwest::blocking::Client,
144    /// Whether the server is answering downloads, as the last of them found.
145    outage: download::Outage,
146    /// Whether the server offers `reportPlayback`, once it has said.
147    playback_report: OnceLock<bool>,
148}
149
150/// What a playback report says the player is doing.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum PlaybackReportState {
153    Playing,
154    Paused,
155    Stopped,
156}
157
158impl PlaybackReportState {
159    fn as_str(self) -> &'static str {
160        match self {
161            Self::Playing => "playing",
162            Self::Paused => "paused",
163            Self::Stopped => "stopped",
164        }
165    }
166}
167
168impl SubsonicClient {
169    pub fn new(base_url: &str, username: &str, password: &str) -> Self {
170        Self::from_auth(SubsonicAuth::new(base_url, username, password))
171    }
172
173    pub fn from_auth(auth: SubsonicAuth) -> Self {
174        Self {
175            auth,
176            http: download::api_client().unwrap_or_else(|e| {
177                log::warn!("falling back to default HTTP client: {}", e);
178                reqwest::blocking::Client::new()
179            }),
180            downloader: download::download_client().unwrap_or_else(|e| {
181                log::warn!("falling back to default download client: {}", e);
182                reqwest::blocking::Client::new()
183            }),
184            outage: download::Outage::default(),
185            playback_report: OnceLock::new(),
186        }
187    }
188
189    /// Whether downloads from this server are waiting out an outage.
190    pub fn outage(&self) -> &download::Outage {
191        &self.outage
192    }
193
194    fn auth_params(&self) -> Result<HashMap<String, String>, SubsonicError> {
195        self.auth.params()
196    }
197
198    /// Make a GET request to a Subsonic API endpoint.
199    fn get(&self, endpoint: &str) -> Result<SubsonicResponse, SubsonicError> {
200        self.get_with_params(endpoint, &[])
201    }
202
203    fn get_with_params(
204        &self,
205        endpoint: &str,
206        extra: &[(&str, &str)],
207    ) -> Result<SubsonicResponse, SubsonicError> {
208        let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
209        let mut params = self.auth_params()?;
210        for (k, v) in extra {
211            params.insert((*k).to_string(), (*v).to_string());
212        }
213
214        let resp: SubsonicResponseWrapper = self.http.get(&url).query(&params).send()?.json()?;
215        resp.subsonic_response.ok()
216    }
217
218    /// As `get_with_params`, sent as a form POST (OpenSubsonic `formPost`): for
219    /// endpoints that change something and take only POST, and for anything
220    /// that must not sit in a URL.
221    fn post_with_params(
222        &self,
223        endpoint: &str,
224        extra: &[(&str, &str)],
225    ) -> Result<SubsonicResponse, SubsonicError> {
226        let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
227        let mut params = self.auth_params()?;
228        for (k, v) in extra {
229            params.insert((*k).to_string(), (*v).to_string());
230        }
231        let resp: SubsonicResponseWrapper = self.http.post(&url).form(&params).send()?.json()?;
232        resp.subsonic_response.ok()
233    }
234
235    /// As `get_with_params`, for a parameter given more than once: Subsonic
236    /// batches by repeating `id` and `time`.
237    fn get_with_pairs(
238        &self,
239        endpoint: &str,
240        pairs: &[(&str, String)],
241    ) -> Result<SubsonicResponse, SubsonicError> {
242        let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
243        let params = self.auth_params()?;
244        let resp: SubsonicResponseWrapper = self
245            .http
246            .get(&url)
247            .query(&params)
248            .query(pairs)
249            .send()?
250            .json()?;
251        resp.subsonic_response.ok()
252    }
253
254    /// Detect a Subsonic error returned from an endpoint that should have sent
255    /// binary data.
256    ///
257    /// Subsonic signals failure with HTTP 200 and a JSON or XML error body, so
258    /// checking the status code proves nothing here — without this, an error
259    /// response gets written to disk as if it were audio.
260    fn reject_error_body(resp: &reqwest::blocking::Response) -> Result<(), SubsonicError> {
261        let is_document = resp
262            .headers()
263            .get(reqwest::header::CONTENT_TYPE)
264            .and_then(|v| v.to_str().ok())
265            .is_some_and(|ct| ct.contains("json") || ct.contains("xml"));
266        if is_document {
267            return Err(SubsonicError::BadResponse);
268        }
269        if !resp.status().is_success() {
270            return Err(SubsonicError::BadResponse);
271        }
272        Ok(())
273    }
274
275    /// Fetch cover art bytes for a song or album ID.
276    ///
277    /// Returns the raw image rather than a parsed response — `getCoverArt`
278    /// answers with image data, not JSON, so it can't go through `get()`.
279    /// `size` requests a square thumbnail; omit it for the original.
280    pub fn get_cover_art(&self, id: &str, size: Option<u32>) -> Result<Vec<u8>, SubsonicError> {
281        let url = format!("{}/rest/getCoverArt", self.base_url());
282        let mut params = self.auth_params()?;
283        params.insert("id".into(), id.to_string());
284        if let Some(px) = size {
285            params.insert("size".into(), px.to_string());
286        }
287
288        let resp = self.http.get(&url).query(&params).send()?;
289        Self::reject_error_body(&resp)?;
290        Ok(resp.bytes()?.to_vec())
291    }
292
293    /// Ping the server — verify connection and credentials.
294    pub fn ping(&self) -> Result<(), SubsonicError> {
295        self.get("ping")?;
296        Ok(())
297    }
298
299    /// Which server this is, by its OpenSubsonic `type`. One round trip.
300    pub fn server_type(&self) -> Result<Option<String>, SubsonicError> {
301        Ok(self.get("ping")?.server_type)
302    }
303
304    /// What this server says it is and which OpenSubsonic extensions it
305    /// offers: `ping`, then `getOpenSubsonicExtensions` when it speaks
306    /// OpenSubsonic. A server that does not has no extensions to list.
307    pub fn profile(&self) -> Result<crate::remote::profile::ServerProfile, SubsonicError> {
308        let ping = self.get("ping")?;
309        let extensions = if ping.open_subsonic {
310            self.get("getOpenSubsonicExtensions")
311                .ok()
312                .and_then(|r| r.open_subsonic_extensions)
313                .unwrap_or_default()
314                .into_iter()
315                .map(|e| (e.name, e.versions))
316                .collect()
317        } else {
318            Vec::new()
319        };
320        Ok(crate::remote::profile::ServerProfile {
321            kind: ping.server_type,
322            version: ping.server_version,
323            open_subsonic: ping.open_subsonic,
324            extensions,
325        })
326    }
327
328    /// Get all artists (indexed).
329    pub fn get_artists(&self) -> Result<Vec<SubsonicArtist>, SubsonicError> {
330        let resp = self.get("getArtists")?;
331        let artists_data = resp.artists.ok_or(SubsonicError::BadResponse)?;
332        let mut all = Vec::new();
333        for index in artists_data.index {
334            all.extend(index.artist);
335        }
336        Ok(all)
337    }
338
339    /// Get an album by ID, including its tracks.
340    pub fn get_album(&self, id: &str) -> Result<SubsonicAlbumFull, SubsonicError> {
341        let resp = self.get_with_params("getAlbum", &[("id", id)])?;
342        resp.album.ok_or(SubsonicError::BadResponse)
343    }
344
345    /// Get a paginated list of albums.
346    pub fn get_album_list(
347        &self,
348        list_type: &str,
349        size: u32,
350        offset: u32,
351    ) -> Result<Vec<SubsonicAlbum>, SubsonicError> {
352        let size_str = size.to_string();
353        let offset_str = offset.to_string();
354        let resp = self.get_with_params(
355            "getAlbumList2",
356            &[
357                ("type", list_type),
358                ("size", &size_str),
359                ("offset", &offset_str),
360            ],
361        )?;
362        Ok(resp.album_list2.map(|al| al.album).unwrap_or_default())
363    }
364
365    /// Build the streaming URL for a track (doesn't make a request).
366    pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
367        self.auth.stream_url(track_id)
368    }
369
370    /// Stream URL without auth params — safe for database storage.
371    pub fn stream_url_template(&self, track_id: &str) -> String {
372        format!("{}/rest/stream?id={}", self.auth.base_url, track_id)
373    }
374
375    /// Download a track to a local path.
376    pub fn download(&self, track_id: &str, dest: &Path) -> Result<(), SubsonicError> {
377        self.fetch_to_file("download", track_id, dest, None, |_, _| {})
378    }
379
380    /// Download a track with progress reporting.
381    ///
382    /// The callback receives `(bytes_downloaded, total_bytes)`; total is 0 when
383    /// the server sends no Content-Length, and the count restarts from zero if
384    /// an attempt is retried. `dest` only appears once the file is complete.
385    ///
386    /// A server that is not answering is waited out, however long that takes,
387    /// until `cancelled` says the track is no longer wanted.
388    pub fn download_with_progress(
389        &self,
390        track_id: &str,
391        dest: &Path,
392        cancelled: &dyn Fn() -> bool,
393        on_progress: impl Fn(u64, u64),
394    ) -> Result<(), SubsonicError> {
395        let patience = download::Patience {
396            outage: &self.outage,
397            cancelled,
398        };
399        self.fetch_to_file("download", track_id, dest, Some(patience), on_progress)
400    }
401
402    /// Fetch a track through `/rest/stream` instead of `/rest/download`.
403    ///
404    /// `download` returns the untranscoded original and is what library sync
405    /// wants from Navidrome. koan's own server implements only `stream`, so
406    /// that is how the remote bridge pulls audio from a `koan serve` instance.
407    pub fn stream_to_file(
408        &self,
409        track_id: &str,
410        dest: &Path,
411        on_progress: impl Fn(u64, u64),
412    ) -> Result<(), SubsonicError> {
413        self.fetch_to_file("stream", track_id, dest, None, on_progress)
414    }
415
416    fn fetch_to_file(
417        &self,
418        endpoint: &str,
419        track_id: &str,
420        dest: &Path,
421        patience: Option<download::Patience<'_>>,
422        on_progress: impl Fn(u64, u64),
423    ) -> Result<(), SubsonicError> {
424        let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
425        download::download_with_retries(
426            dest,
427            download::DEFAULT_ATTEMPTS,
428            patience,
429            || {
430                // Fresh auth params per attempt — the salt must not be replayed.
431                let mut params = self
432                    .auth_params()
433                    .map_err(|e| download::DownloadError::Request(e.to_string()))?;
434                params.insert("id".into(), track_id.to_string());
435                Ok(self.downloader.get(&url).query(&params))
436            },
437            on_progress,
438        )?;
439        Ok(())
440    }
441
442    /// One page of every song on the server, `size` from `offset`.
443    ///
444    /// An empty `search3` query lists the whole library on OpenSubsonic
445    /// servers (Navidrome, koan). Older servers answer it with nothing or an
446    /// error, which is the caller's cue to walk albums one at a time instead.
447    pub fn all_songs_page(
448        &self,
449        size: u32,
450        offset: u32,
451    ) -> Result<Vec<SubsonicSong>, SubsonicError> {
452        let size = size.to_string();
453        let offset = offset.to_string();
454        let resp = self.get_with_params(
455            "search3",
456            &[
457                ("query", ""),
458                ("artistCount", "0"),
459                ("albumCount", "0"),
460                ("songCount", &size),
461                ("songOffset", &offset),
462            ],
463        )?;
464        Ok(resp.search_result3.map(|r| r.song).unwrap_or_default())
465    }
466
467    /// How many songs the server says it has, from `getScanStatus`. `None`
468    /// where the server does not count.
469    pub fn song_count(&self) -> Result<Option<u64>, SubsonicError> {
470        let resp = self.get("getScanStatus")?;
471        Ok(resp.scan_status.and_then(|s| s.count))
472    }
473
474    /// When the server's library last changed, in milliseconds, as
475    /// `getIndexes` reports it. Asked `since` the version last seen, so a
476    /// library that has not moved answers with the timestamp alone. `None`
477    /// where the server gives none.
478    pub fn library_modified(&self, since: Option<i64>) -> Result<Option<i64>, SubsonicError> {
479        let since = since.map(|v| v.to_string());
480        let params: Vec<(&str, &str)> = since
481            .as_deref()
482            .map(|v| vec![("ifModifiedSince", v)])
483            .unwrap_or_default();
484        let resp = self.get_with_params("getIndexes", &params)?;
485        Ok(resp.indexes.and_then(|i| i.last_modified))
486    }
487
488    /// Search for tracks/albums/artists.
489    pub fn search(&self, query: &str) -> Result<SubsonicSearchResult, SubsonicError> {
490        let resp = self.get_with_params("search3", &[("query", query)])?;
491        Ok(resp.search_result3.unwrap_or_default())
492    }
493
494    /// Scrobble a play that counts, dated to `heard_at_ms` (ms since the
495    /// epoch the listen began).
496    pub fn scrobble(&self, track_id: &str, heard_at_ms: u64) -> Result<(), SubsonicError> {
497        let at = heard_at_ms.to_string();
498        self.get_with_params(
499            "scrobble",
500            &[("id", track_id), ("submission", "true"), ("time", &at)],
501        )?;
502        Ok(())
503    }
504
505    /// Scrobble several plays in one request, `(track id, started at ms)`.
506    pub fn scrobble_many(&self, plays: &[(&str, i64)]) -> Result<(), SubsonicError> {
507        let mut pairs = vec![("submission", "true".to_owned())];
508        for (id, at) in plays {
509            pairs.push(("id", (*id).to_owned()));
510            pairs.push(("time", at.to_string()));
511        }
512        self.get_with_pairs("scrobble", &pairs)?;
513        Ok(())
514    }
515
516    /// Tell the server where playback of a track stands, so its Now Playing
517    /// follows pauses, seeks and stops.
518    ///
519    /// Uses OpenSubsonic's `reportPlayback` where the server offers it, always
520    /// with `ignoreScrobble`: koan decides what counts as a play and scrobbles
521    /// it itself. Elsewhere only `playing` can be said, as a now-playing
522    /// `scrobble` from `position` (seconds, as Navidrome reads it); a pause or
523    /// a stop has no equivalent there and is not sent.
524    pub fn report_playback(
525        &self,
526        track_id: &str,
527        state: PlaybackReportState,
528        position_ms: u64,
529    ) -> Result<(), SubsonicError> {
530        if self.supports_playback_report() {
531            let position = position_ms.to_string();
532            self.get_with_params(
533                "reportPlayback",
534                &[
535                    ("mediaId", track_id),
536                    ("mediaType", "song"),
537                    ("positionMs", &position),
538                    ("state", state.as_str()),
539                    ("ignoreScrobble", "true"),
540                ],
541            )?;
542            return Ok(());
543        }
544        if state == PlaybackReportState::Playing {
545            let position = (position_ms / 1000).to_string();
546            self.get_with_params(
547                "scrobble",
548                &[
549                    ("id", track_id),
550                    ("submission", "false"),
551                    ("position", &position),
552                ],
553            )?;
554        }
555        Ok(())
556    }
557
558    /// Whether the server advertises the `playbackReport` extension. Asked
559    /// once per client; any answer, including an error, is kept. Only a
560    /// failure to reach the server at all is asked again next time.
561    fn supports_playback_report(&self) -> bool {
562        if let Some(&known) = self.playback_report.get() {
563            return known;
564        }
565        let supported = match self.get("getOpenSubsonicExtensions") {
566            Ok(resp) => resp.has_extension(PLAYBACK_REPORT_EXTENSION),
567            Err(SubsonicError::Http(e)) if e.is_connect() || e.is_timeout() => return false,
568            Err(_) => false,
569        };
570        *self.playback_report.get_or_init(|| supported)
571    }
572
573    /// Star (favourite) a track on the server.
574    pub fn star(&self, track_id: &str) -> Result<(), SubsonicError> {
575        self.get_with_params("star", &[("id", track_id)])?;
576        Ok(())
577    }
578
579    /// Unstar (unfavourite) a track on the server.
580    pub fn unstar(&self, track_id: &str) -> Result<(), SubsonicError> {
581        self.get_with_params("unstar", &[("id", track_id)])?;
582        Ok(())
583    }
584
585    /// Everything the server has starred: songs, albums and artists.
586    ///
587    /// Subsonic returns all three from one call, so asking for songs alone
588    /// leaves a starred album invisible to us for no saving.
589    pub fn get_starred_all(&self) -> Result<SubsonicStarred, SubsonicError> {
590        let resp = self.get("getStarred2")?;
591        Ok(resp.starred2.unwrap_or_default())
592    }
593
594    /// Star an album. Subsonic keys this off a different parameter to a song —
595    /// `id` would be read as a track and silently star nothing.
596    pub fn star_album(&self, album_id: &str) -> Result<(), SubsonicError> {
597        self.get_with_params("star", &[("albumId", album_id)])?;
598        Ok(())
599    }
600
601    pub fn unstar_album(&self, album_id: &str) -> Result<(), SubsonicError> {
602        self.get_with_params("unstar", &[("albumId", album_id)])?;
603        Ok(())
604    }
605
606    pub fn star_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
607        self.get_with_params("star", &[("artistId", artist_id)])?;
608        Ok(())
609    }
610
611    pub fn unstar_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
612        self.get_with_params("unstar", &[("artistId", artist_id)])?;
613        Ok(())
614    }
615
616    /// Create a sharing link for one or more resources (songs, albums, etc).
617    /// Returns the created share including its ID which forms the public URL.
618    pub fn create_share(
619        &self,
620        ids: &[&str],
621        description: Option<&str>,
622    ) -> Result<SubsonicShare, SubsonicError> {
623        let url = format!("{}/rest/createShare", self.auth.base_url);
624        let mut params = self.auth_params()?;
625        if let Some(desc) = description {
626            params.insert("description".into(), desc.to_string());
627        }
628
629        // Subsonic API takes `id` as a repeated param for multiple resources.
630        let mut query: Vec<(String, String)> = params.into_iter().collect();
631        for id in ids {
632            query.push(("id".into(), (*id).to_string()));
633        }
634
635        let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
636
637        let inner = resp.subsonic_response;
638        if inner.status != "ok" {
639            if let Some(err) = inner.error {
640                return Err(SubsonicError::Api {
641                    code: err.code,
642                    message: err.message,
643                });
644            }
645            return Err(SubsonicError::BadResponse);
646        }
647
648        inner
649            .shares
650            .and_then(|s| s.share.into_iter().next())
651            .ok_or(SubsonicError::BadResponse)
652    }
653
654    /// Get similar songs for a track (Subsonic getSimilarSongs2 endpoint).
655    /// Returns up to `count` similar songs based on the server's algorithm.
656    pub fn get_similar_songs(
657        &self,
658        song_id: &str,
659        count: usize,
660    ) -> Result<Vec<SubsonicSong>, SubsonicError> {
661        let count_str = count.to_string();
662        let resp = self.get_with_params(
663            "getSimilarSongs2",
664            &[("id", song_id), ("count", &count_str)],
665        )?;
666        Ok(resp.similar_songs2.and_then(|s| s.song).unwrap_or_default())
667    }
668
669    // --- Playlists ---------------------------------------------------------
670
671    /// Every playlist the server will show this user, without their contents.
672    pub fn get_playlists(&self) -> Result<Vec<SubsonicPlaylist>, SubsonicError> {
673        let resp = self.get("getPlaylists")?;
674        Ok(resp.playlists.map(|p| p.playlist).unwrap_or_default())
675    }
676
677    /// One playlist, with its songs in order.
678    pub fn get_playlist(&self, id: &str) -> Result<SubsonicPlaylistFull, SubsonicError> {
679        let resp = self.get_with_params("getPlaylist", &[("id", id)])?;
680        resp.playlist.ok_or(SubsonicError::BadResponse)
681    }
682
683    /// Create a playlist, or replace an existing one's contents wholesale.
684    ///
685    /// `createPlaylist` is the only Subsonic call that can set a playlist's
686    /// order: `updatePlaylist` appends and removes by index, which cannot
687    /// express a reorder. Passing `playlist_id` turns this into "these songs,
688    /// in this order, from now on", which is exactly what koan has after any
689    /// edit — so every push takes this path and there is one way for the two
690    /// sides to disagree instead of five.
691    pub fn create_playlist(
692        &self,
693        playlist_id: Option<&str>,
694        name: &str,
695        song_ids: &[String],
696    ) -> Result<Option<SubsonicPlaylistFull>, SubsonicError> {
697        let url = format!("{}/rest/createPlaylist", self.auth.base_url);
698        let mut params = self.auth_params()?;
699        match playlist_id {
700            Some(id) => {
701                params.insert("playlistId".into(), id.to_string());
702                // Navidrome keeps the stored name when updating, but a rename
703                // that happened offline has to travel somehow.
704                params.insert("name".into(), name.to_string());
705            }
706            None => {
707                params.insert("name".into(), name.to_string());
708            }
709        }
710
711        // Repeated `songId`, in order — that order is the playlist.
712        let mut query: Vec<(String, String)> = params.into_iter().collect();
713        for id in song_ids {
714            query.push(("songId".into(), id.clone()));
715        }
716
717        let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
718        let inner = resp.subsonic_response;
719        if inner.status != "ok" {
720            if let Some(err) = inner.error {
721                return Err(SubsonicError::Api {
722                    code: err.code,
723                    message: err.message,
724                });
725            }
726            return Err(SubsonicError::BadResponse);
727        }
728        // Servers before 1.14.0 answer with an empty body, so an absent
729        // playlist here is not an error — only a caller that needed the new id
730        // has a problem, and it says so itself.
731        Ok(inner.playlist)
732    }
733
734    /// Change what can be changed without touching the song list.
735    pub fn update_playlist(
736        &self,
737        id: &str,
738        name: Option<&str>,
739        comment: Option<&str>,
740        public: Option<bool>,
741    ) -> Result<(), SubsonicError> {
742        let mut extra: Vec<(&str, String)> = vec![("playlistId", id.to_string())];
743        if let Some(name) = name {
744            extra.push(("name", name.to_string()));
745        }
746        if let Some(comment) = comment {
747            extra.push(("comment", comment.to_string()));
748        }
749        if let Some(public) = public {
750            extra.push(("public", public.to_string()));
751        }
752        let borrowed: Vec<(&str, &str)> = extra.iter().map(|(k, v)| (*k, v.as_str())).collect();
753        self.get_with_params("updatePlaylist", &borrowed)?;
754        Ok(())
755    }
756
757    pub fn delete_playlist(&self, id: &str) -> Result<(), SubsonicError> {
758        self.get_with_params("deletePlaylist", &[("id", id)])?;
759        Ok(())
760    }
761
762    // -- Accounts: koan servers only, and only for an admin --
763
764    pub fn koan_users(&self) -> Result<Vec<KoanUser>, SubsonicError> {
765        Ok(self
766            .get("koanUsers")?
767            .users
768            .map(|u| u.user)
769            .unwrap_or_default())
770    }
771
772    /// `role` is `admin`, `user` or `readonly`.
773    pub fn koan_create_user(
774        &self,
775        username: &str,
776        role: &str,
777    ) -> Result<KoanInvite, SubsonicError> {
778        self.get_with_params("koanCreateUser", &[("username", username), ("role", role)])?
779            .invite
780            .ok_or(SubsonicError::BadResponse)
781    }
782
783    /// With `reset`, the account gets a new password, which comes back with the
784    /// invite, and its devices sign out.
785    pub fn koan_invite(&self, username: &str, reset: bool) -> Result<KoanInvite, SubsonicError> {
786        let reset = if reset { "true" } else { "false" };
787        self.get_with_params("koanInvite", &[("username", username), ("reset", reset)])?
788            .invite
789            .ok_or(SubsonicError::BadResponse)
790    }
791
792    /// Revoke the API key this client signs in with (`koanRevokeKey`).
793    pub fn koan_revoke_own_key(&self) -> Result<(), SubsonicError> {
794        self.get("koanRevokeKey")?;
795        Ok(())
796    }
797
798    /// The device waiting on pairing `pair`, an id or a code, and where it
799    /// asked from (`koanPairInfo`).
800    pub fn koan_pair_info(&self, pair: &str) -> Result<KoanPair, SubsonicError> {
801        self.get_with_params("koanPairInfo", &[("pair", pair)])?
802            .pair
803            .ok_or(SubsonicError::BadResponse)
804    }
805
806    /// Sign the device waiting on `pair` in as this account, or with
807    /// `decline`, turn it away (`koanPairApprove`). Answers with its name.
808    pub fn koan_pair_approve(&self, pair: &str, decline: bool) -> Result<String, SubsonicError> {
809        let decline = if decline { "true" } else { "false" };
810        self.get_with_params("koanPairApprove", &[("pair", pair), ("decline", decline)])?
811            .pair
812            .map(|p| p.device)
813            .ok_or(SubsonicError::BadResponse)
814    }
815
816    /// Give `username` a password (`koanSetUserPassword`): an admin's call for
817    /// another account. Its devices sign out.
818    pub fn koan_set_user_password(
819        &self,
820        username: &str,
821        password: &str,
822    ) -> Result<(), SubsonicError> {
823        self.post_with_params(
824            "koanSetUserPassword",
825            &[("username", username), ("password", password)],
826        )?;
827        Ok(())
828    }
829
830    /// Change this account's own password, proving the current one. Every key
831    /// the account had is revoked, this client's included, so the server
832    /// answers with a new one named `device`.
833    pub fn koan_change_own_password(
834        &self,
835        current: &str,
836        password: &str,
837        device: &str,
838    ) -> Result<KoanJoined, SubsonicError> {
839        self.post_with_params(
840            "koanSetUserPassword",
841            &[
842                ("current", current),
843                ("password", password),
844                ("name", device),
845            ],
846        )?
847        .join
848        .ok_or(SubsonicError::BadResponse)
849    }
850
851    /// Where assistants connect to this server, and the page saying how: koan
852    /// servers offering `koanMcp`.
853    pub fn koan_mcp(&self) -> Result<KoanMcp, SubsonicError> {
854        self.get("koanMcp")?.mcp.ok_or(SubsonicError::BadResponse)
855    }
856
857    // -- API keys: koan servers offering `koanApiKeys`, the account's own --
858
859    pub fn koan_api_keys(&self) -> Result<Vec<KoanApiKey>, SubsonicError> {
860        Ok(self
861            .get("koanApiKeys")?
862            .api_keys
863            .map(|k| k.api_key)
864            .unwrap_or_default())
865    }
866
867    /// A new key named `name`, with the key itself, which is never shown again.
868    pub fn koan_create_api_key(&self, name: &str) -> Result<KoanApiKey, SubsonicError> {
869        self.post_with_params("koanCreateApiKey", &[("name", name)])?
870            .api_key
871            .ok_or(SubsonicError::BadResponse)
872    }
873
874    pub fn koan_revoke_api_key(&self, id: i64) -> Result<(), SubsonicError> {
875        self.post_with_params("koanRevokeApiKey", &[("id", &id.to_string())])?;
876        Ok(())
877    }
878
879    // -- App passwords: koan servers offering `koanAppPasswords`, the account's own --
880
881    pub fn koan_app_passwords(&self) -> Result<Vec<KoanAppPassword>, SubsonicError> {
882        Ok(self
883            .get("koanAppPasswords")?
884            .app_passwords
885            .map(|p| p.app_password)
886            .unwrap_or_default())
887    }
888
889    /// A new app password named `name`, with the password itself, which is
890    /// never shown again.
891    pub fn koan_create_app_password(&self, name: &str) -> Result<KoanAppPassword, SubsonicError> {
892        self.post_with_params("koanCreateAppPassword", &[("name", name)])?
893            .app_password
894            .ok_or(SubsonicError::BadResponse)
895    }
896
897    pub fn koan_revoke_app_password(&self, id: i64) -> Result<(), SubsonicError> {
898        self.post_with_params("koanRevokeAppPassword", &[("id", &id.to_string())])?;
899        Ok(())
900    }
901
902    pub fn koan_set_user_role(&self, username: &str, role: &str) -> Result<(), SubsonicError> {
903        self.get_with_params("koanSetUserRole", &[("username", username), ("role", role)])?;
904        Ok(())
905    }
906
907    pub fn koan_delete_user(&self, username: &str) -> Result<(), SubsonicError> {
908        self.get_with_params("koanDeleteUser", &[("username", username)])?;
909        Ok(())
910    }
911
912    /// Have the server hand `command` (a link command, as JSON) to the device
913    /// `to` on this account: a koan extension, `koanDevices`.
914    /// Ask the server to get `command` (JSON) to the device `to`. With `ack`,
915    /// a server offering `koanAck` waits a moment for the device and says how
916    /// it went; one that does not answers `None`.
917    pub fn koan_command(
918        &self,
919        to: &str,
920        command: &str,
921        ack: Option<u64>,
922    ) -> Result<Option<crate::remote::acks::AckOutcome>, SubsonicError> {
923        let ack = ack.map(|a| a.to_string());
924        let mut params = vec![("to", to), ("command", command)];
925        if let Some(ack) = &ack {
926            params.push(("ack", ack));
927        }
928        Ok(self.get_with_params("koanCommand", &params)?.koan_command)
929    }
930
931    // -- Play history: koan servers offering `koanHistory` --
932
933    /// The account's plays and forgettings after `since`, at most `count` of
934    /// each.
935    pub fn koan_history(
936        &self,
937        since: crate::db::queries::HistoryCursor,
938        count: u32,
939    ) -> Result<KoanHistoryPage, SubsonicError> {
940        self.get_with_params(
941            "koanHistory",
942            &[("since", &since.to_string()), ("count", &count.to_string())],
943        )?
944        .koan_history
945        .ok_or(SubsonicError::BadResponse)
946    }
947
948    /// Forget these plays, `(track id, started at ms)`, for every device on
949    /// the account.
950    pub fn koan_forget_plays(&self, plays: &[(&str, i64)]) -> Result<(), SubsonicError> {
951        let mut pairs = Vec::new();
952        for (id, at) in plays {
953            pairs.push(("id", (*id).to_owned()));
954            pairs.push(("time", at.to_string()));
955        }
956        self.get_with_pairs("koanForgetPlays", &pairs)?;
957        Ok(())
958    }
959
960    /// Forget every play up to `at_ms`, for every device on the account.
961    pub fn koan_forget_plays_through(&self, at_ms: i64) -> Result<(), SubsonicError> {
962        self.get_with_params("koanForgetPlays", &[("through", &at_ms.to_string())])?;
963        Ok(())
964    }
965
966    // -- Scrobbling: koan servers offering `koanScrobbling` --
967
968    /// Where the account's plays are forwarded.
969    pub fn koan_scrobbling(&self) -> Result<KoanScrobbling, SubsonicError> {
970        self.get("koanScrobbling")?
971            .koan_scrobbling
972            .ok_or(SubsonicError::BadResponse)
973    }
974
975    /// Connect the account's ListenBrainz with its user token, which the
976    /// server checks with ListenBrainz before keeping.
977    pub fn koan_scrobbling_connect(&self, token: &str) -> Result<KoanScrobbling, SubsonicError> {
978        self.post_with_params("koanScrobblingConnect", &[("token", token)])?
979            .koan_scrobbling
980            .ok_or(SubsonicError::BadResponse)
981    }
982
983    pub fn koan_scrobbling_disconnect(&self) -> Result<KoanScrobbling, SubsonicError> {
984        self.get("koanScrobblingDisconnect")?
985            .koan_scrobbling
986            .ok_or(SubsonicError::BadResponse)
987    }
988
989    // -- EQ profiles: koan servers offering `koanDspProfiles` --
990
991    /// The account's profiles changed after revision `since`.
992    pub fn koan_dsp_profiles(&self, since: i64) -> Result<KoanDspProfiles, SubsonicError> {
993        self.get_with_params("koanDspProfiles", &[("since", &since.to_string())])?
994            .koan_dsp_profiles
995            .ok_or(SubsonicError::BadResponse)
996    }
997
998    /// Keep `doc` as profile `uid`'s, edited at `edited_at` (ms).
999    pub fn koan_dsp_profile_save(
1000        &self,
1001        uid: &str,
1002        edited_at: i64,
1003        doc: &str,
1004    ) -> Result<KoanDspSaved, SubsonicError> {
1005        self.post_with_params(
1006            "koanDspProfileSave",
1007            &[
1008                ("uid", uid),
1009                ("editedAt", &edited_at.to_string()),
1010                ("doc", doc),
1011            ],
1012        )?
1013        .koan_dsp_saved
1014        .ok_or(SubsonicError::BadResponse)
1015    }
1016
1017    pub fn koan_dsp_profile_delete(
1018        &self,
1019        uid: &str,
1020        edited_at: i64,
1021    ) -> Result<KoanDspSaved, SubsonicError> {
1022        self.post_with_params(
1023            "koanDspProfileDelete",
1024            &[("uid", uid), ("editedAt", &edited_at.to_string())],
1025        )?
1026        .koan_dsp_saved
1027        .ok_or(SubsonicError::BadResponse)
1028    }
1029
1030    /// The account's profiles deleted in the last thirty days: servers
1031    /// offering `koanDspDeleted`.
1032    pub fn koan_dsp_deleted(&self) -> Result<Vec<KoanDspDeletedProfile>, SubsonicError> {
1033        Ok(self
1034            .get("koanDspDeleted")?
1035            .koan_dsp_deleted
1036            .map(|d| d.profile)
1037            .unwrap_or_default())
1038    }
1039
1040    /// Save deleted profile `uid` again from the server's copy, as a new edit.
1041    pub fn koan_dsp_restore(&self, uid: &str) -> Result<KoanDspSaved, SubsonicError> {
1042        self.post_with_params("koanDspRestore", &[("uid", uid)])?
1043            .koan_dsp_saved
1044            .ok_or(SubsonicError::BadResponse)
1045    }
1046
1047    /// Turn the AutoEQ suggestion for `output` down on every device.
1048    pub fn koan_dsp_dismiss(&self, output: &str) -> Result<(), SubsonicError> {
1049        self.post_with_params("koanDspDismiss", &[("output", output)])?;
1050        Ok(())
1051    }
1052
1053    /// A file a profile names, by its SHA-256.
1054    pub fn koan_dsp_file(&self, sha256: &str) -> Result<Vec<u8>, SubsonicError> {
1055        let url = format!("{}/rest/koanDspFile", self.auth.base_url);
1056        let mut params = self.auth_params()?;
1057        params.insert("sha256".into(), sha256.into());
1058        let resp = self
1059            .http
1060            .get(&url)
1061            .query(&params)
1062            .timeout(DSP_FILE_TIMEOUT)
1063            .send()?;
1064        let binary = resp
1065            .headers()
1066            .get(reqwest::header::CONTENT_TYPE)
1067            .is_some_and(|t| t.as_bytes().starts_with(b"application/octet-stream"));
1068        if !binary {
1069            let resp: SubsonicResponseWrapper = resp.json()?;
1070            resp.subsonic_response.ok()?;
1071            return Err(SubsonicError::BadResponse);
1072        }
1073        Ok(resp.bytes()?.to_vec())
1074    }
1075
1076    /// Send a file a saved profile names and the server lacks.
1077    pub fn koan_dsp_upload(&self, sha256: &str, data: Vec<u8>) -> Result<(), SubsonicError> {
1078        let url = format!("{}/rest/koanDspFile", self.auth.base_url);
1079        let mut params = self.auth_params()?;
1080        params.insert("sha256".into(), sha256.into());
1081        let resp: SubsonicResponseWrapper = self
1082            .http
1083            .post(&url)
1084            .query(&params)
1085            .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
1086            .body(data)
1087            .timeout(DSP_FILE_TIMEOUT)
1088            .send()?
1089            .json()?;
1090        resp.subsonic_response.ok()?;
1091        Ok(())
1092    }
1093
1094    /// The account's saved play queue, `None` when there is none. `by_index`
1095    /// for a server listing `indexBasedQueue`.
1096    pub fn get_play_queue(&self, by_index: bool) -> Result<Option<SavedPlayQueue>, SubsonicError> {
1097        let resp = self.get(if by_index {
1098            "getPlayQueueByIndex"
1099        } else {
1100            "getPlayQueue"
1101        })?;
1102        Ok(resp.play_queue_by_index.or(resp.play_queue))
1103    }
1104
1105    /// Replace the account's saved play queue with `song_ids`, `position_ms`
1106    /// into the entry at `current`, as the client `client`: what a later
1107    /// `get_play_queue` reports as `changedBy`. `form` posts it as a form, for
1108    /// a server listing `formPost`, since a long queue does not fit in a URL;
1109    /// otherwise it goes in the query. `by_index` for a server listing
1110    /// `indexBasedQueue`; otherwise the current entry goes by its song's id,
1111    /// which is ambiguous for a song queued twice.
1112    #[allow(clippy::too_many_arguments)]
1113    pub fn save_play_queue(
1114        &self,
1115        song_ids: &[String],
1116        current: Option<usize>,
1117        position_ms: u64,
1118        by_index: bool,
1119        form: bool,
1120        client: &str,
1121    ) -> Result<(), SubsonicError> {
1122        let endpoint = if by_index {
1123            "savePlayQueueByIndex"
1124        } else {
1125            "savePlayQueue"
1126        };
1127        let url = format!("{}/rest/{endpoint}", self.auth.base_url);
1128        let mut params = self.auth_params()?;
1129        params.insert("c".into(), client.to_string());
1130        let mut form_pairs: Vec<(String, String)> = params.into_iter().collect();
1131        form_pairs.extend(song_ids.iter().map(|id| ("id".to_string(), id.clone())));
1132        // With songs, a current one is required by index; the first will do
1133        // when none is known.
1134        if !song_ids.is_empty() {
1135            let at = current.filter(|at| *at < song_ids.len()).unwrap_or(0);
1136            form_pairs.push(if by_index {
1137                ("currentIndex".into(), at.to_string())
1138            } else {
1139                ("current".into(), song_ids[at].clone())
1140            });
1141            form_pairs.push(("position".into(), position_ms.to_string()));
1142        }
1143        let request = if form {
1144            let body = url::form_urlencoded::Serializer::new(String::new())
1145                .extend_pairs(&form_pairs)
1146                .finish();
1147            self.http
1148                .post(&url)
1149                .header(
1150                    reqwest::header::CONTENT_TYPE,
1151                    "application/x-www-form-urlencoded",
1152                )
1153                .body(body)
1154        } else {
1155            self.http.get(&url).query(&form_pairs)
1156        };
1157        let resp: SubsonicResponseWrapper = request.send()?.json()?;
1158        resp.subsonic_response.ok().map(|_| ())
1159    }
1160
1161    pub fn auth(&self) -> &SubsonicAuth {
1162        &self.auth
1163    }
1164
1165    /// The configured server base URL (for constructing share links etc).
1166    pub fn base_url(&self) -> &str {
1167        &self.auth.base_url
1168    }
1169}
1170
1171// --- Response types ---
1172
1173#[derive(Debug, Deserialize)]
1174struct SubsonicResponseWrapper {
1175    #[serde(rename = "subsonic-response")]
1176    subsonic_response: SubsonicResponse,
1177}
1178
1179#[derive(Debug, Deserialize)]
1180#[serde(rename_all = "camelCase")]
1181struct SubsonicResponse {
1182    status: String,
1183    /// The OpenSubsonic server name ("navidrome", "koan"); absent on servers
1184    /// that predate OpenSubsonic.
1185    #[serde(rename = "type")]
1186    server_type: Option<String>,
1187    server_version: Option<String>,
1188    #[serde(default)]
1189    open_subsonic: bool,
1190    open_subsonic_extensions: Option<Vec<SubsonicExtension>>,
1191    error: Option<SubsonicApiError>,
1192    artists: Option<SubsonicArtists>,
1193    album: Option<SubsonicAlbumFull>,
1194    album_list2: Option<SubsonicAlbumList>,
1195    search_result3: Option<SubsonicSearchResult>,
1196    starred2: Option<SubsonicStarred>,
1197    shares: Option<SubsonicShares>,
1198    similar_songs2: Option<SubsonicSimilarSongs>,
1199    playlists: Option<SubsonicPlaylists>,
1200    playlist: Option<SubsonicPlaylistFull>,
1201    scan_status: Option<SubsonicScanStatus>,
1202    indexes: Option<SubsonicIndexes>,
1203    users: Option<KoanUsers>,
1204    api_keys: Option<KoanApiKeys>,
1205    api_key: Option<KoanApiKey>,
1206    app_passwords: Option<KoanAppPasswords>,
1207    app_password: Option<KoanAppPassword>,
1208    mcp: Option<KoanMcp>,
1209    invite: Option<KoanInvite>,
1210    join: Option<KoanJoined>,
1211    pair: Option<KoanPair>,
1212    koan_history: Option<KoanHistoryPage>,
1213    play_queue: Option<SavedPlayQueue>,
1214    play_queue_by_index: Option<SavedPlayQueue>,
1215    koan_command: Option<crate::remote::acks::AckOutcome>,
1216    koan_scrobbling: Option<KoanScrobbling>,
1217    koan_dsp_profiles: Option<KoanDspProfiles>,
1218    koan_dsp_saved: Option<KoanDspSaved>,
1219    koan_dsp_deleted: Option<KoanDspDeleted>,
1220}
1221
1222/// The play queue the account saved on the server: `getPlayQueue` names the
1223/// current song by id, `getPlayQueueByIndex` by its place.
1224#[derive(Debug, Clone, Default, Deserialize)]
1225#[serde(rename_all = "camelCase")]
1226pub struct SavedPlayQueue {
1227    #[serde(default)]
1228    pub entry: Vec<SubsonicSong>,
1229    pub current: Option<String>,
1230    pub current_index: Option<usize>,
1231    #[serde(default)]
1232    pub position: u64,
1233    #[serde(default)]
1234    pub changed_by: String,
1235    pub changed: Option<String>,
1236}
1237
1238impl SavedPlayQueue {
1239    /// The current entry's place, by index when the server gave one, else
1240    /// the first entry with the current song's id.
1241    pub fn current_at(&self) -> Option<usize> {
1242        self.current_index
1243            .filter(|at| *at < self.entry.len())
1244            .or_else(|| {
1245                let current = self.current.as_deref()?;
1246                self.entry.iter().position(|e| e.id == current)
1247            })
1248    }
1249}
1250
1251/// How long a profile's file may take to move: up to 32 MiB on a slow
1252/// connection.
1253const DSP_FILE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
1254
1255/// The account's EQ profiles changed after a revision (`koanDspProfiles`).
1256#[derive(Debug, Clone, Default, Deserialize)]
1257pub struct KoanDspProfiles {
1258    /// The revision to read from next.
1259    pub cursor: i64,
1260    #[serde(default)]
1261    pub profile: Vec<KoanDspProfile>,
1262    /// Outputs the account turned AutoEQ down for.
1263    #[serde(default)]
1264    pub dismissed: Vec<KoanDspDismissed>,
1265}
1266
1267#[derive(Debug, Clone, Deserialize)]
1268#[serde(rename_all = "camelCase")]
1269pub struct KoanDspProfile {
1270    pub uid: String,
1271    pub rev: i64,
1272    pub edited_at: i64,
1273    /// `remote::dsp_sync::SyncDoc` as JSON; absent once deleted.
1274    pub doc: Option<String>,
1275}
1276
1277#[derive(Debug, Clone, Deserialize)]
1278pub struct KoanDspDismissed {
1279    pub output: String,
1280}
1281
1282#[derive(Debug, Clone, Default, Deserialize)]
1283struct KoanDspDeleted {
1284    #[serde(default)]
1285    profile: Vec<KoanDspDeletedProfile>,
1286}
1287
1288/// A profile deleted in the last thirty days, which the server keeps a copy
1289/// of (`koanDspDeleted`). Times are ms by the server's clock.
1290#[derive(Debug, Clone, Deserialize)]
1291#[serde(rename_all = "camelCase")]
1292pub struct KoanDspDeletedProfile {
1293    pub uid: String,
1294    pub name: String,
1295    pub deleted_at: i64,
1296    pub expires_at: i64,
1297}
1298
1299/// What a save or deletion did (`koanDspSaved`).
1300#[derive(Debug, Clone, Default, Deserialize)]
1301pub struct KoanDspSaved {
1302    pub rev: i64,
1303    /// False when the server kept a copy edited later.
1304    pub stored: bool,
1305    /// Files the profile names that the server lacks, to send.
1306    #[serde(default)]
1307    pub missing: Vec<KoanDspMissing>,
1308}
1309
1310#[derive(Debug, Clone, Deserialize)]
1311pub struct KoanDspMissing {
1312    pub sha256: String,
1313}
1314
1315/// The services a koan server forwards the account's plays to
1316/// (`koanScrobbling`).
1317#[derive(Debug, Clone, Default, Deserialize)]
1318pub struct KoanScrobbling {
1319    #[serde(default)]
1320    pub service: Vec<KoanScrobbleService>,
1321}
1322
1323#[derive(Debug, Clone, Deserialize)]
1324pub struct KoanScrobbleService {
1325    /// `listenbrainz`.
1326    pub name: String,
1327    /// The account on the service.
1328    pub account: String,
1329    /// When it was connected, in ms since the epoch.
1330    pub connected: i64,
1331    /// Plays the service has not accepted yet.
1332    #[serde(default)]
1333    pub pending: i64,
1334    /// Why the service stopped accepting the token, while it does.
1335    pub error: Option<String>,
1336}
1337
1338/// A page of a koan server's play history (`koanHistory`).
1339#[derive(Debug, Clone, Default, Deserialize)]
1340pub struct KoanHistoryPage {
1341    /// Where the next page starts.
1342    pub cursor: String,
1343    #[serde(default)]
1344    pub more: bool,
1345    #[serde(default)]
1346    pub play: Vec<KoanPlay>,
1347    #[serde(default)]
1348    pub forgotten: Vec<KoanForgotten>,
1349}
1350
1351#[derive(Debug, Clone, Deserialize)]
1352#[serde(rename_all = "camelCase")]
1353pub struct KoanPlay {
1354    /// The track's id.
1355    pub id: String,
1356    /// The play's place in the server's history.
1357    #[serde(default)]
1358    pub seq: Option<i64>,
1359    /// When it started, in ms since the epoch.
1360    pub played: i64,
1361    pub listened_ms: Option<i64>,
1362}
1363
1364/// A play forgotten, or with no `id` every play up to `played`.
1365#[derive(Debug, Clone, Deserialize)]
1366pub struct KoanForgotten {
1367    pub id: Option<String>,
1368    pub played: i64,
1369}
1370
1371/// A pairing a koan server holds, as `koanPairInfo` and `koanPairApprove`
1372/// describe it: the device, the address it asked from, and whether that
1373/// address is on a private network.
1374#[derive(Debug, Clone, Deserialize)]
1375pub struct KoanPair {
1376    pub device: String,
1377    #[serde(default)]
1378    pub from: String,
1379    #[serde(default)]
1380    pub local: bool,
1381}
1382
1383/// A koan server's MCP endpoint and its page on connecting an assistant.
1384#[derive(Debug, Clone, Deserialize)]
1385pub struct KoanMcp {
1386    pub url: String,
1387    pub connect: String,
1388}
1389
1390#[derive(Debug, Deserialize)]
1391#[serde(rename_all = "camelCase")]
1392struct KoanApiKeys {
1393    #[serde(default)]
1394    api_key: Vec<KoanApiKey>,
1395}
1396
1397/// One of the account's API keys, as `koanApiKeys` lists it; `key` is there
1398/// only in the answer to making one.
1399#[derive(Debug, Clone, Deserialize)]
1400#[serde(rename_all = "camelCase")]
1401pub struct KoanApiKey {
1402    pub id: i64,
1403    pub name: String,
1404    /// ISO 8601.
1405    pub created: Option<String>,
1406    pub last_used: Option<String>,
1407    /// The key this client signs in with.
1408    #[serde(default)]
1409    pub current: bool,
1410    pub key: Option<String>,
1411}
1412
1413#[derive(Debug, Deserialize)]
1414#[serde(rename_all = "camelCase")]
1415struct KoanAppPasswords {
1416    #[serde(default)]
1417    app_password: Vec<KoanAppPassword>,
1418}
1419
1420/// One of the account's app passwords, as `koanAppPasswords` lists it;
1421/// `password` is there only in the answer to making one.
1422#[derive(Debug, Clone, Deserialize)]
1423#[serde(rename_all = "camelCase")]
1424pub struct KoanAppPassword {
1425    pub id: i64,
1426    pub name: String,
1427    /// ISO 8601.
1428    pub created: Option<String>,
1429    pub last_used: Option<String>,
1430    pub password: Option<String>,
1431}
1432
1433#[derive(Debug, Deserialize)]
1434struct KoanUsers {
1435    #[serde(default)]
1436    user: Vec<KoanUser>,
1437}
1438
1439/// An account on a koan server, as its admins see it.
1440#[derive(Debug, Clone, Deserialize)]
1441pub struct KoanUser {
1442    pub username: String,
1443    pub role: String,
1444}
1445
1446/// What a koan server hands back for an invite: the token the link carries,
1447/// and the password when one was just made. The link is built from the
1448/// address the client already reaches the server at.
1449#[derive(Debug, Clone, Deserialize)]
1450pub struct KoanInvite {
1451    pub username: String,
1452    pub token: String,
1453    pub password: Option<String>,
1454}
1455
1456/// An invite redeemed: the account it was for and the API key made for this
1457/// device.
1458#[derive(Debug, Clone, Deserialize)]
1459#[serde(rename_all = "camelCase")]
1460pub struct KoanJoined {
1461    pub username: String,
1462    pub api_key: String,
1463}
1464
1465/// Trade a koan invite token for an API key (`koanJoin`), naming the key for
1466/// `device`. Unauthenticated: the token is the credential.
1467pub fn redeem_invite(
1468    base_url: &str,
1469    token: &str,
1470    device: &str,
1471) -> Result<KoanJoined, SubsonicError> {
1472    let url = format!("{}/rest/koanJoin", base_url.trim_end_matches('/'));
1473    let resp: SubsonicResponseWrapper = download::api_client()?
1474        .get(&url)
1475        .query(&[
1476            ("invite", token),
1477            ("name", device),
1478            ("v", API_VERSION),
1479            ("c", CLIENT_NAME),
1480            ("f", "json"),
1481        ])
1482        .send()?
1483        .json()?;
1484    resp.subsonic_response
1485        .ok()?
1486        .join
1487        .ok_or(SubsonicError::BadResponse)
1488}
1489
1490/// Whether the server at `base_url` lists `extension`, asked without
1491/// credentials: OpenSubsonic answers `getOpenSubsonicExtensions` to anyone, so
1492/// a client can learn what a server is before it sends a password to it.
1493pub fn offers_unsigned(base_url: &str, extension: &str) -> Result<bool, SubsonicError> {
1494    let url = format!(
1495        "{}/rest/getOpenSubsonicExtensions",
1496        base_url.trim_end_matches('/')
1497    );
1498    let resp: SubsonicResponseWrapper = download::api_client()?
1499        .get(&url)
1500        .query(&[("v", API_VERSION), ("c", CLIENT_NAME), ("f", "json")])
1501        .send()?
1502        .json()?;
1503    Ok(resp.subsonic_response.ok()?.has_extension(extension))
1504}
1505
1506/// Trade an account's password for an API key named for `device`
1507/// (`koanSignIn`). The password goes as `p=enc:`, over plain HTTP too: this
1508/// once, so that it is never sent again. Only for a server that offers
1509/// `profile::SIGN_IN`.
1510pub fn koan_sign_in(
1511    base_url: &str,
1512    username: &str,
1513    password: &str,
1514    device: &str,
1515) -> Result<KoanJoined, SubsonicError> {
1516    let url = format!("{}/rest/koanSignIn", base_url.trim_end_matches('/'));
1517    let hex: String = password.bytes().map(|b| format!("{b:02x}")).collect();
1518    let p = format!("enc:{hex}");
1519    let resp: SubsonicResponseWrapper = download::api_client()?
1520        .get(&url)
1521        .query(&[
1522            ("u", username),
1523            ("p", &p),
1524            ("name", device),
1525            ("v", API_VERSION),
1526            ("c", CLIENT_NAME),
1527            ("f", "json"),
1528        ])
1529        .send()?
1530        .json()?;
1531    resp.subsonic_response
1532        .ok()?
1533        .join
1534        .ok_or(SubsonicError::BadResponse)
1535}
1536
1537impl SubsonicResponse {
1538    fn ok(self) -> Result<Self, SubsonicError> {
1539        if self.status == "ok" {
1540            return Ok(self);
1541        }
1542        Err(self
1543            .error
1544            .map_or(SubsonicError::BadResponse, |err| SubsonicError::Api {
1545                code: err.code,
1546                message: err.message,
1547            }))
1548    }
1549
1550    fn has_extension(&self, name: &str) -> bool {
1551        self.open_subsonic_extensions
1552            .iter()
1553            .flatten()
1554            .any(|ext| ext.name == name)
1555    }
1556}
1557
1558#[derive(Debug, Deserialize)]
1559struct SubsonicExtension {
1560    name: String,
1561    #[serde(default)]
1562    versions: Vec<i64>,
1563}
1564
1565#[derive(Debug, Deserialize)]
1566#[serde(rename_all = "camelCase")]
1567struct SubsonicIndexes {
1568    last_modified: Option<i64>,
1569}
1570
1571#[derive(Debug, Deserialize)]
1572struct SubsonicScanStatus {
1573    count: Option<u64>,
1574}
1575
1576#[derive(Debug, Deserialize)]
1577struct SubsonicApiError {
1578    code: i32,
1579    message: String,
1580}
1581
1582#[derive(Debug, Deserialize)]
1583struct SubsonicArtists {
1584    index: Vec<SubsonicArtistIndex>,
1585}
1586
1587#[derive(Debug, Deserialize)]
1588struct SubsonicArtistIndex {
1589    artist: Vec<SubsonicArtist>,
1590}
1591
1592#[derive(Debug, Clone, Deserialize)]
1593#[serde(rename_all = "camelCase")]
1594pub struct SubsonicArtist {
1595    pub id: String,
1596    pub name: String,
1597    pub album_count: Option<i32>,
1598    // OpenSubsonic. Both arrive in `getArtists`, so keeping them costs no
1599    // extra request.
1600    #[serde(default, deserialize_with = "non_empty")]
1601    pub music_brainz_id: Option<String>,
1602    #[serde(default, deserialize_with = "non_empty")]
1603    pub sort_name: Option<String>,
1604}
1605
1606#[derive(Debug, Clone, Deserialize)]
1607#[serde(rename_all = "camelCase")]
1608pub struct SubsonicAlbum {
1609    pub id: String,
1610    pub name: String,
1611    pub artist: Option<String>,
1612    pub artist_id: Option<String>,
1613    pub song_count: Option<i32>,
1614    /// Seconds, summed over the album's songs.
1615    #[serde(default)]
1616    pub duration: Option<i64>,
1617    pub year: Option<i32>,
1618    #[serde(default, deserialize_with = "non_empty")]
1619    pub genre: Option<String>,
1620    pub created: Option<String>,
1621    // OpenSubsonic. All of these arrive in `getAlbumList2`, which the sync
1622    // already pages through.
1623    #[serde(default, deserialize_with = "non_empty")]
1624    pub music_brainz_id: Option<String>,
1625    #[serde(default, deserialize_with = "non_empty")]
1626    pub sort_name: Option<String>,
1627    #[serde(default)]
1628    pub record_labels: Vec<SubsonicName>,
1629}
1630
1631/// A bare `{"name": "..."}` object. The server uses this shape for record
1632/// labels, genres and moods alike.
1633#[derive(Debug, Clone, Deserialize)]
1634pub struct SubsonicName {
1635    pub name: String,
1636}
1637
1638#[derive(Debug, Clone, Deserialize)]
1639#[serde(rename_all = "camelCase")]
1640pub struct SubsonicAlbumFull {
1641    pub id: String,
1642    pub name: String,
1643    pub artist: Option<String>,
1644    pub artist_id: Option<String>,
1645    pub year: Option<i32>,
1646    #[serde(default, deserialize_with = "non_empty")]
1647    pub genre: Option<String>,
1648    pub song_count: Option<i32>,
1649    pub created: Option<String>,
1650    #[serde(default, deserialize_with = "non_empty")]
1651    pub music_brainz_id: Option<String>,
1652    #[serde(default, deserialize_with = "non_empty")]
1653    pub sort_name: Option<String>,
1654    #[serde(default)]
1655    pub record_labels: Vec<SubsonicName>,
1656    #[serde(default)]
1657    pub song: Vec<SubsonicSong>,
1658}
1659
1660#[derive(Debug, Clone, Deserialize)]
1661#[serde(rename_all = "camelCase")]
1662pub struct SubsonicSong {
1663    pub id: String,
1664    pub title: String,
1665    pub album: Option<String>,
1666    pub artist: Option<String>,
1667    pub track: Option<i32>,
1668    pub disc_number: Option<i32>,
1669    pub year: Option<i32>,
1670    #[serde(default, deserialize_with = "non_empty")]
1671    pub genre: Option<String>,
1672    pub duration: Option<i64>,
1673    pub bit_rate: Option<i32>,
1674    pub suffix: Option<String>,
1675    pub content_type: Option<String>,
1676    /// Sent by a koan server only; a suffix cannot tell ALAC from AAC.
1677    #[serde(default, deserialize_with = "non_empty")]
1678    pub codec: Option<String>,
1679    pub album_id: Option<String>,
1680    pub artist_id: Option<String>,
1681    // OpenSubsonic. Absent on a plain Subsonic server, which is why they are
1682    // Options rather than defaults — a missing sample rate is not 0 Hz.
1683    pub sampling_rate: Option<i32>,
1684    pub bit_depth: Option<i32>,
1685    pub channel_count: Option<i32>,
1686    #[serde(default, deserialize_with = "non_empty")]
1687    pub music_brainz_id: Option<String>,
1688}
1689
1690#[derive(Debug, Deserialize)]
1691struct SubsonicAlbumList {
1692    #[serde(default)]
1693    album: Vec<SubsonicAlbum>,
1694}
1695
1696#[derive(Debug, Default, Deserialize)]
1697pub struct SubsonicSearchResult {
1698    #[serde(default)]
1699    pub artist: Vec<SubsonicArtist>,
1700    #[serde(default)]
1701    pub album: Vec<SubsonicAlbum>,
1702    #[serde(default)]
1703    pub song: Vec<SubsonicSong>,
1704}
1705
1706#[derive(Debug, Default, Deserialize)]
1707pub struct SubsonicStarred {
1708    #[serde(default)]
1709    pub song: Vec<SubsonicSong>,
1710    #[serde(default)]
1711    pub album: Vec<SubsonicAlbum>,
1712    #[serde(default)]
1713    pub artist: Vec<SubsonicArtist>,
1714}
1715
1716#[derive(Debug, Deserialize)]
1717pub struct SubsonicSimilarSongs {
1718    pub song: Option<Vec<SubsonicSong>>,
1719}
1720
1721#[derive(Debug, Default, Deserialize)]
1722struct SubsonicPlaylists {
1723    #[serde(default)]
1724    playlist: Vec<SubsonicPlaylist>,
1725}
1726
1727/// A playlist as the server describes it, without its songs.
1728#[derive(Debug, Clone, Deserialize)]
1729#[serde(rename_all = "camelCase")]
1730pub struct SubsonicPlaylist {
1731    pub id: String,
1732    pub name: String,
1733    pub comment: Option<String>,
1734    pub owner: Option<String>,
1735    #[serde(default)]
1736    pub public: bool,
1737    pub song_count: Option<i64>,
1738    pub duration: Option<i64>,
1739    pub created: Option<String>,
1740    pub changed: Option<String>,
1741    /// OpenSubsonic: the server takes no edits to its contents (a smart
1742    /// playlist).
1743    #[serde(default)]
1744    pub readonly: bool,
1745}
1746
1747#[derive(Debug, Clone, Deserialize)]
1748#[serde(rename_all = "camelCase")]
1749pub struct SubsonicPlaylistFull {
1750    #[serde(flatten)]
1751    pub playlist: SubsonicPlaylist,
1752    #[serde(default)]
1753    pub entry: Vec<SubsonicSong>,
1754}
1755
1756#[derive(Debug, Deserialize)]
1757struct SubsonicShares {
1758    #[serde(default)]
1759    share: Vec<SubsonicShare>,
1760}
1761
1762#[derive(Debug, Clone, Deserialize)]
1763#[serde(rename_all = "camelCase")]
1764pub struct SubsonicShare {
1765    pub id: String,
1766    pub url: Option<String>,
1767    pub description: Option<String>,
1768    pub username: Option<String>,
1769    pub created: Option<String>,
1770    pub expires: Option<String>,
1771    pub visit_count: Option<i64>,
1772}
1773
1774/// An empty string as absent. OpenSubsonic servers send every field they
1775/// support, empty where there is no value — koan's own sends
1776/// `musicBrainzId: ""` for an untagged track. Kept as `Some("")`, that id
1777/// would match every other untagged track: the MusicBrainz dedup would pair
1778/// unrelated tracks on it, scanning the whole table per insert to do so, and
1779/// album enrichment would write `""` over a missing id.
1780fn non_empty<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
1781    Ok(Option::<String>::deserialize(d)?.filter(|s| !s.is_empty()))
1782}
1783
1784/// Generate a random hex salt string for Subsonic auth.
1785///
1786/// The salt goes on the wire next to `md5(password + salt)`, so it has to be
1787/// unpredictable — a clock- or counter-derived fallback would make the token
1788/// precomputable from a captured exchange. A request without OS entropy fails
1789/// rather than authenticating weakly.
1790fn random_salt() -> Result<String, getrandom::Error> {
1791    let mut buf = [0u8; 12];
1792    getrandom::fill(&mut buf)?;
1793    Ok(buf.iter().map(|b| format!("{:02x}", b)).collect())
1794}
1795
1796#[cfg(test)]
1797mod tests {
1798
1799    #[test]
1800    fn a_saved_play_queue_reads_by_index_or_by_id() {
1801        let parse = |json: &str| {
1802            serde_json::from_str::<SubsonicResponseWrapper>(json)
1803                .unwrap()
1804                .subsonic_response
1805        };
1806        let entries = r#"[{"id":"a","title":"A"},{"id":"b","title":"B"},{"id":"a","title":"A"}]"#;
1807        let by_index = parse(&format!(
1808            r#"{{"subsonic-response":{{"status":"ok","playQueueByIndex":{{"entry":{entries},"currentIndex":2,"position":1500,"changedBy":"koan d1"}}}}}}"#
1809        ));
1810        let q = by_index.play_queue_by_index.unwrap();
1811        assert_eq!(
1812            (q.current_at(), q.position, q.changed_by.as_str()),
1813            (Some(2), 1500, "koan d1")
1814        );
1815
1816        // By id, the first entry with that song; a song not queued is none.
1817        let by_id = parse(&format!(
1818            r#"{{"subsonic-response":{{"status":"ok","playQueue":{{"entry":{entries},"current":"a"}}}}}}"#
1819        ));
1820        assert_eq!(by_id.play_queue.unwrap().current_at(), Some(0));
1821        let gone = parse(&format!(
1822            r#"{{"subsonic-response":{{"status":"ok","playQueue":{{"entry":{entries},"current":"z"}}}}}}"#
1823        ));
1824        assert_eq!(gone.play_queue.unwrap().current_at(), None);
1825    }
1826
1827    use super::*;
1828
1829    fn response(json: &str) -> SubsonicResponse {
1830        serde_json::from_str::<SubsonicResponseWrapper>(json)
1831            .unwrap()
1832            .subsonic_response
1833    }
1834
1835    #[test]
1836    fn http_errors_leave_the_signed_url_out() {
1837        // Nothing listens on port 1, so this fails to connect.
1838        let e = reqwest::blocking::get("http://127.0.0.1:1/rest/stream?u=owner&p=enc:736563726574")
1839            .unwrap_err();
1840        assert!(e.to_string().contains("enc:736563726574"));
1841        let shown = SubsonicError::from(e).to_string();
1842        assert!(!shown.contains("enc:"), "{shown}");
1843        let e = reqwest::blocking::get("http://127.0.0.1:1/rest/stream?p=enc:736563726574")
1844            .unwrap_err();
1845        assert!(!DownloadError::from(e).to_string().contains("enc:"));
1846    }
1847
1848    #[test]
1849    fn playback_report_is_read_from_the_advertised_extensions() {
1850        let navidrome = response(
1851            r#"{"subsonic-response": {
1852                "status": "ok", "version": "1.16.1", "type": "navidrome",
1853                "openSubsonic": true,
1854                "openSubsonicExtensions": [
1855                    {"name": "transcodeOffset", "versions": [1]},
1856                    {"name": "playbackReport", "versions": [1]}
1857                ]
1858            }}"#,
1859        );
1860        assert!(navidrome.has_extension(PLAYBACK_REPORT_EXTENSION));
1861
1862        let without = response(
1863            r#"{"subsonic-response": {
1864                "status": "ok", "version": "1.16.1",
1865                "openSubsonicExtensions": [{"name": "songLyrics", "versions": [1, 2]}]
1866            }}"#,
1867        );
1868        assert!(!without.has_extension(PLAYBACK_REPORT_EXTENSION));
1869
1870        let plain = response(r#"{"subsonic-response": {"status": "ok", "version": "1.16.1"}}"#);
1871        assert!(!plain.has_extension(PLAYBACK_REPORT_EXTENSION));
1872    }
1873
1874    // --- SubsonicSong deserialization ---
1875
1876    #[test]
1877    fn test_deserialize_subsonic_song() {
1878        let json = r#"{
1879            "id": "42",
1880            "title": "Space Oddity",
1881            "album": "Space Oddity",
1882            "artist": "David Bowie",
1883            "track": 1,
1884            "discNumber": 1,
1885            "year": 1969,
1886            "genre": "Rock",
1887            "duration": 314,
1888            "bitRate": 320,
1889            "suffix": "mp3",
1890            "contentType": "audio/mpeg",
1891            "albumId": "7",
1892            "artistId": "3"
1893        }"#;
1894
1895        let song: SubsonicSong = serde_json::from_str(json).unwrap();
1896
1897        assert_eq!(song.id, "42");
1898        assert_eq!(song.title, "Space Oddity");
1899        assert_eq!(song.album.as_deref(), Some("Space Oddity"));
1900        assert_eq!(song.artist.as_deref(), Some("David Bowie"));
1901        assert_eq!(song.track, Some(1));
1902        assert_eq!(song.disc_number, Some(1));
1903        assert_eq!(song.year, Some(1969));
1904        assert_eq!(song.genre.as_deref(), Some("Rock"));
1905        assert_eq!(song.duration, Some(314));
1906        assert_eq!(song.bit_rate, Some(320));
1907        assert_eq!(song.suffix.as_deref(), Some("mp3"));
1908        assert_eq!(song.content_type.as_deref(), Some("audio/mpeg"));
1909        assert_eq!(song.album_id.as_deref(), Some("7"));
1910        assert_eq!(song.artist_id.as_deref(), Some("3"));
1911    }
1912
1913    /// An OpenSubsonic server reports the figures that make a track's quality
1914    /// legible. Ignoring them left every remote-only track with no sample rate
1915    /// and no bit depth at all.
1916    #[test]
1917    fn opensubsonic_quality_fields_are_read() {
1918        let json = r#"{
1919            "id": "000XtGC7jsWEbOjDsZi4Xw",
1920            "title": "Anguish",
1921            "suffix": "flac",
1922            "bitRate": 913,
1923            "samplingRate": 44100,
1924            "bitDepth": 16,
1925            "channelCount": 2
1926        }"#;
1927
1928        let song: SubsonicSong = serde_json::from_str(json).unwrap();
1929
1930        assert_eq!(song.sampling_rate, Some(44100));
1931        assert_eq!(song.bit_depth, Some(16));
1932        assert_eq!(song.channel_count, Some(2));
1933    }
1934
1935    /// A koan server names the codec, which the suffix cannot: ALAC and AAC
1936    /// are both m4a.
1937    #[test]
1938    fn a_koan_server_names_the_codec() {
1939        let json = r#"{"id": "1", "title": "Seawhite", "suffix": "m4a", "codec": "ALAC"}"#;
1940        let song: SubsonicSong = serde_json::from_str(json).unwrap();
1941        assert_eq!(song.codec.as_deref(), Some("ALAC"));
1942
1943        let json = r#"{"id": "1", "title": "Seawhite", "suffix": "m4a"}"#;
1944        let song: SubsonicSong = serde_json::from_str(json).unwrap();
1945        assert_eq!(song.codec, None);
1946    }
1947
1948    /// A plain Subsonic server omits them, and a missing sample rate is not
1949    /// 0 Hz — the fields have to stay absent rather than default.
1950    #[test]
1951    fn a_plain_subsonic_song_has_no_quality_figures() {
1952        let json = r#"{"id": "1", "title": "Track", "bitRate": 320}"#;
1953        let song: SubsonicSong = serde_json::from_str(json).unwrap();
1954
1955        assert_eq!(song.sampling_rate, None);
1956        assert_eq!(song.bit_depth, None);
1957        assert_eq!(song.channel_count, None);
1958    }
1959
1960    #[test]
1961    fn test_deserialize_subsonic_song_optional_fields_absent() {
1962        // Only the required fields (id, title) — all Option fields should be None.
1963        let json = r#"{"id": "99", "title": "Minimal Track"}"#;
1964
1965        let song: SubsonicSong = serde_json::from_str(json).unwrap();
1966
1967        assert_eq!(song.id, "99");
1968        assert_eq!(song.title, "Minimal Track");
1969        assert!(song.album.is_none());
1970        assert!(song.artist.is_none());
1971        assert!(song.track.is_none());
1972        assert!(song.disc_number.is_none());
1973        assert!(song.year.is_none());
1974        assert!(song.duration.is_none());
1975        assert!(song.bit_rate.is_none());
1976    }
1977
1978    // --- SubsonicAlbum deserialization ---
1979
1980    #[test]
1981    fn test_deserialize_album_list() {
1982        let json = r#"{
1983            "subsonic-response": {
1984                "status": "ok",
1985                "version": "1.16.1",
1986                "albumList2": {
1987                    "album": [
1988                        {
1989                            "id": "1",
1990                            "name": "Abbey Road",
1991                            "artist": "The Beatles",
1992                            "artistId": "10",
1993                            "songCount": 17,
1994                            "year": 1969,
1995                            "genre": "Rock",
1996                            "created": "2020-01-01T00:00:00"
1997                        },
1998                        {
1999                            "id": "2",
2000                            "name": "Led Zeppelin IV",
2001                            "artist": "Led Zeppelin",
2002                            "artistId": "11",
2003                            "songCount": 8,
2004                            "year": 1971,
2005                            "genre": "Hard Rock",
2006                            "created": "2020-01-02T00:00:00"
2007                        }
2008                    ]
2009                }
2010            }
2011        }"#;
2012
2013        let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
2014        let album_list = wrapper
2015            .subsonic_response
2016            .album_list2
2017            .expect("album_list2 should be present");
2018
2019        assert_eq!(album_list.album.len(), 2);
2020
2021        let first = &album_list.album[0];
2022        assert_eq!(first.id, "1");
2023        assert_eq!(first.name, "Abbey Road");
2024        assert_eq!(first.artist.as_deref(), Some("The Beatles"));
2025        assert_eq!(first.artist_id.as_deref(), Some("10"));
2026        assert_eq!(first.song_count, Some(17));
2027        assert_eq!(first.year, Some(1969));
2028
2029        let second = &album_list.album[1];
2030        assert_eq!(second.id, "2");
2031        assert_eq!(second.name, "Led Zeppelin IV");
2032        assert_eq!(second.song_count, Some(8));
2033    }
2034
2035    // --- SubsonicClient auth params ---
2036
2037    #[test]
2038    fn test_auth_params_format() {
2039        let client = SubsonicClient::new("http://localhost:4533", "alice", "secret");
2040        let params = client.auth_params().unwrap();
2041
2042        // Must contain exactly these six keys.
2043        assert!(params.contains_key("u"), "missing 'u' param");
2044        assert!(params.contains_key("t"), "missing 't' param");
2045        assert!(params.contains_key("s"), "missing 's' param");
2046        assert!(params.contains_key("v"), "missing 'v' param");
2047        assert!(params.contains_key("c"), "missing 'c' param");
2048        assert!(params.contains_key("f"), "missing 'f' param");
2049        assert_eq!(params.len(), 6);
2050
2051        assert_eq!(params["u"], "alice");
2052        assert_eq!(params["v"], "1.16.1");
2053        assert_eq!(params["c"], "koan");
2054        assert_eq!(params["f"], "json");
2055    }
2056
2057    #[test]
2058    fn test_auth_params_over_https_send_the_hex_password() {
2059        let client = SubsonicClient::new("https://koan.example", "alice", "hi");
2060        let params = client.auth_params().unwrap();
2061        assert_eq!(params["p"], "enc:6869");
2062        assert!(!params.contains_key("t") && !params.contains_key("s"));
2063    }
2064
2065    #[test]
2066    fn test_auth_params_with_an_api_key_name_no_user() {
2067        let auth = SubsonicAuth::with(
2068            "http://koan.example",
2069            "alice",
2070            Credential::ApiKey("k3y".into()),
2071        );
2072        let params = auth.params().unwrap();
2073        assert_eq!(params.get("apiKey").map(String::as_str), Some("k3y"));
2074        for absent in ["u", "p", "t", "s"] {
2075            assert!(!params.contains_key(absent), "{absent}");
2076        }
2077    }
2078
2079    #[test]
2080    fn test_auth_params_token_is_md5_of_password_plus_salt() {
2081        let client = SubsonicClient::new("http://localhost:4533", "bob", "letmein");
2082        let params = client.auth_params().unwrap();
2083
2084        let salt = &params["s"];
2085        let token = &params["t"];
2086
2087        // The token must equal md5(password + salt).
2088        let expected = format!("{:x}", md5::compute(format!("letmein{}", salt)));
2089        assert_eq!(token, &expected);
2090    }
2091
2092    #[test]
2093    fn test_auth_params_salt_is_different_each_call() {
2094        let client = SubsonicClient::new("http://localhost:4533", "user", "pass");
2095        let params1 = client.auth_params().unwrap();
2096        let params2 = client.auth_params().unwrap();
2097
2098        // Salts should differ across calls (random); tokens will differ too.
2099        // There is a negligible probability they collide — acceptable in tests.
2100        assert_ne!(params1["s"], params2["s"], "salt should be random per call");
2101    }
2102
2103    // --- stream_url ---
2104
2105    #[test]
2106    fn test_stream_url_has_auth() {
2107        let client = SubsonicClient::new("http://myserver:4533", "user", "pass");
2108        let url = client.stream_url("track-123").unwrap();
2109
2110        assert!(url.contains("track-123"), "url must include the track id");
2111        assert!(url.contains("u=user"), "url must include username param");
2112        assert!(url.contains("v=1.16.1"), "url must include api version");
2113        assert!(url.contains("c=koan"), "url must include client name");
2114        assert!(url.contains("f=json"), "url must include format param");
2115        assert!(url.contains("/rest/stream"), "url must target /rest/stream");
2116        assert!(
2117            url.starts_with("http://myserver:4533"),
2118            "url must use the configured base_url"
2119        );
2120    }
2121
2122    #[test]
2123    fn test_stream_url_base_url_trailing_slash_normalised() {
2124        // SubsonicClient::new strips trailing slashes from base_url.
2125        let client_with_slash = SubsonicClient::new("http://myserver:4533/", "u", "p");
2126        let client_no_slash = SubsonicClient::new("http://myserver:4533", "u", "p");
2127
2128        let url_with = client_with_slash.stream_url("1").unwrap();
2129        let url_without = client_no_slash.stream_url("1").unwrap();
2130
2131        // Both should produce the same path prefix (no double slash).
2132        assert!(
2133            url_with.contains("/rest/stream"),
2134            "should not have double slash"
2135        );
2136        assert!(!url_with.contains("//rest"), "should not have double slash");
2137        // Both base URLs normalise to the same path structure.
2138        assert_eq!(
2139            url_with.split('?').next(),
2140            url_without.split('?').next(),
2141            "path segment should be identical regardless of trailing slash"
2142        );
2143    }
2144
2145    // --- SubsonicAlbumFull deserialization ---
2146
2147    #[test]
2148    fn test_deserialize_album_full_with_songs() {
2149        let json = r#"{
2150            "id": "5",
2151            "name": "Kind of Blue",
2152            "artist": "Miles Davis",
2153            "artistId": "20",
2154            "year": 1959,
2155            "genre": "Jazz",
2156            "songCount": 5,
2157            "created": "2021-06-01T00:00:00",
2158            "song": [
2159                {"id": "101", "title": "So What"},
2160                {"id": "102", "title": "Freddie Freeloader"},
2161                {"id": "103", "title": "Blue in Green"}
2162            ]
2163        }"#;
2164
2165        let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
2166
2167        assert_eq!(album.id, "5");
2168        assert_eq!(album.name, "Kind of Blue");
2169        assert_eq!(album.artist.as_deref(), Some("Miles Davis"));
2170        assert_eq!(album.year, Some(1959));
2171        assert_eq!(album.song.len(), 3);
2172        assert_eq!(album.song[0].title, "So What");
2173        assert_eq!(album.song[2].id, "103");
2174    }
2175
2176    #[test]
2177    fn test_deserialize_album_full_empty_song_list() {
2178        // When `song` key is absent, the #[serde(default)] should yield an empty Vec.
2179        let json = r#"{"id": "9", "name": "No Tracks Yet"}"#;
2180
2181        let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
2182
2183        assert_eq!(album.id, "9");
2184        assert!(album.song.is_empty(), "song list should default to empty");
2185    }
2186
2187    // --- SubsonicSearchResult deserialization ---
2188
2189    #[test]
2190    fn test_deserialize_search_result_mixed() {
2191        let json = r#"{
2192            "artist": [{"id": "1", "name": "Artist One"}],
2193            "album":  [{"id": "2", "name": "Album One"}],
2194            "song":   [{"id": "3", "title": "Song One"}]
2195        }"#;
2196
2197        let result: SubsonicSearchResult = serde_json::from_str(json).unwrap();
2198
2199        assert_eq!(result.artist.len(), 1);
2200        assert_eq!(result.artist[0].name, "Artist One");
2201        assert_eq!(result.album.len(), 1);
2202        assert_eq!(result.album[0].name, "Album One");
2203        assert_eq!(result.song.len(), 1);
2204        assert_eq!(result.song[0].title, "Song One");
2205    }
2206
2207    #[test]
2208    fn empty_opensubsonic_ids_are_absent() {
2209        let json = r#"{"id": "1", "title": "T", "musicBrainzId": ""}"#;
2210        let song: SubsonicSong = serde_json::from_str(json).unwrap();
2211        assert_eq!(song.music_brainz_id, None);
2212
2213        let json = r#"{"id": "2", "name": "A", "musicBrainzId": "", "sortName": ""}"#;
2214        let album: SubsonicAlbum = serde_json::from_str(json).unwrap();
2215        assert_eq!((album.music_brainz_id, album.sort_name), (None, None));
2216
2217        let json = r#"{"id": "3", "name": "A", "musicBrainzId": "mb-1"}"#;
2218        let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
2219        assert_eq!(album.music_brainz_id.as_deref(), Some("mb-1"));
2220        assert_eq!(album.sort_name, None);
2221    }
2222
2223    #[test]
2224    fn deleted_dsp_profiles_read_with_their_times() {
2225        let listed = response(
2226            r#"{"subsonic-response": {"status": "ok", "koanDspDeleted": {"profile": [
2227                {"uid": "u1", "name": "Room", "deletedAt": 1000, "expiresAt": 2592001000}
2228            ]}}}"#,
2229        );
2230        let listed = listed.koan_dsp_deleted.unwrap().profile;
2231        assert_eq!(listed.len(), 1);
2232        assert_eq!(
2233            (listed[0].uid.as_str(), listed[0].name.as_str()),
2234            ("u1", "Room")
2235        );
2236        assert_eq!(
2237            (listed[0].deleted_at, listed[0].expires_at),
2238            (1000, 2_592_001_000)
2239        );
2240        let none = response(r#"{"subsonic-response": {"status": "ok", "koanDspDeleted": {}}}"#);
2241        assert!(none.koan_dsp_deleted.unwrap().profile.is_empty());
2242    }
2243
2244    #[test]
2245    fn app_passwords_read_listed_and_made() {
2246        let listed = response(
2247            r#"{"subsonic-response": {"status": "ok", "appPasswords": {"appPassword": [
2248                {"id": 3, "name": "Arpeggi", "created": "2026-10-08T09:00:00Z"},
2249                {"id": 4, "name": "Tempo", "created": "2026-10-08T10:00:00Z", "lastUsed": "2026-10-08T11:00:00Z"}
2250            ]}}}"#,
2251        );
2252        let listed = listed.app_passwords.unwrap().app_password;
2253        assert_eq!(listed.len(), 2);
2254        assert_eq!((listed[0].id, listed[0].name.as_str()), (3, "Arpeggi"));
2255        assert_eq!(listed[0].last_used, None);
2256        assert_eq!(listed[1].last_used.as_deref(), Some("2026-10-08T11:00:00Z"));
2257        assert!(listed.iter().all(|p| p.password.is_none()));
2258
2259        let none = response(r#"{"subsonic-response": {"status": "ok", "appPasswords": {}}}"#);
2260        assert!(none.app_passwords.unwrap().app_password.is_empty());
2261
2262        let made = response(
2263            r#"{"subsonic-response": {"status": "ok", "appPassword": {"id": 5, "name": "Feishin", "password": "s3cret"}}}"#,
2264        );
2265        let made = made.app_password.unwrap();
2266        assert_eq!((made.id, made.password.as_deref()), (5, Some("s3cret")));
2267    }
2268
2269    #[test]
2270    fn test_deserialize_scan_status_count() {
2271        let json = r#"{"subsonic-response":{"status":"ok","scanStatus":{"scanning":false,"count":49700}}}"#;
2272        let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
2273        let status = wrapper.subsonic_response.scan_status.unwrap();
2274        assert_eq!(status.count, Some(49_700));
2275    }
2276
2277    #[test]
2278    fn test_deserialize_search_result_defaults_to_empty() {
2279        // All three lists are #[serde(default)], so an empty object is valid.
2280        let result: SubsonicSearchResult = serde_json::from_str("{}").unwrap();
2281
2282        assert!(result.artist.is_empty());
2283        assert!(result.album.is_empty());
2284        assert!(result.song.is_empty());
2285    }
2286}