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