Skip to main content

koan_core/remote/
client.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::OnceLock;
4
5use serde::Deserialize;
6use thiserror::Error;
7
8use super::download::{self, DownloadError};
9
10const API_VERSION: &str = "1.16.1";
11const CLIENT_NAME: &str = "koan";
12const PLAYBACK_REPORT_EXTENSION: &str = "playbackReport";
13
14#[derive(Debug, Error)]
15pub enum SubsonicError {
16    #[error("http error: {0}")]
17    Http(reqwest::Error),
18    #[error("api error: {code} — {message}")]
19    Api { code: i32, message: String },
20    #[error("unexpected response format")]
21    BadResponse,
22    #[error("io error: {0}")]
23    Io(#[from] std::io::Error),
24    #[error("download error: {0}")]
25    Download(#[from] DownloadError),
26    #[error("entropy source unavailable: {0}")]
27    Entropy(#[from] getrandom::Error),
28}
29
30/// Without the URL: every request is signed with the account's credentials in
31/// its query, and the error's message would carry them to wherever it is shown
32/// or logged.
33impl From<reqwest::Error> for SubsonicError {
34    fn from(e: reqwest::Error) -> Self {
35        Self::Http(e.without_url())
36    }
37}
38
39/// A Subsonic server and the credentials that sign requests to it.
40///
41/// Kept separate from `SubsonicClient` because constructing that builds two
42/// blocking `reqwest` clients, each carrying its own runtime — doing so from
43/// inside a tokio runtime panics. A caller that only needs a signed URL, such
44/// as koan's own Subsonic proxy, holds this instead.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct SubsonicAuth {
47    pub base_url: String,
48    /// Who the credential signs in as. Not sent with an API key, which names
49    /// its account itself, but still what the account is known by locally.
50    pub username: String,
51    pub credential: Credential,
52}
53
54/// What signs each request.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum Credential {
57    Password(String),
58    /// OpenSubsonic's `apiKeyAuthentication`. What redeeming a koan invite
59    /// gives.
60    ApiKey(String),
61}
62
63impl SubsonicAuth {
64    pub fn new(base_url: &str, username: &str, password: &str) -> Self {
65        Self::with(
66            base_url,
67            username,
68            Credential::Password(password.to_string()),
69        )
70    }
71
72    pub fn with(base_url: &str, username: &str, credential: Credential) -> Self {
73        Self {
74            base_url: base_url.trim_end_matches('/').to_string(),
75            username: username.to_string(),
76            credential,
77        }
78    }
79
80    /// Build auth query params: `apiKey`, or u then p (HTTPS) or t and s; then
81    /// v, c, f.
82    ///
83    /// Over HTTPS a password goes as `p=enc:<hex>`: a koan server checks
84    /// accounts against an argon2 hash, which token auth cannot be checked
85    /// against. Over plain HTTP that would expose the password, so the salted
86    /// token is sent instead, as every Subsonic server accepts.
87    fn params(&self) -> Result<HashMap<String, String>, SubsonicError> {
88        let mut params = HashMap::new();
89        match &self.credential {
90            Credential::ApiKey(key) => {
91                params.insert("apiKey".into(), key.clone());
92            }
93            Credential::Password(password) => {
94                params.insert("u".into(), self.username.clone());
95                if self.base_url.starts_with("https://") {
96                    let hex: String = password.bytes().map(|b| format!("{b:02x}")).collect();
97                    params.insert("p".into(), format!("enc:{hex}"));
98                } else {
99                    let salt = random_salt()?;
100                    let token = format!("{:x}", md5::compute(format!("{password}{salt}")));
101                    params.insert("t".into(), token);
102                    params.insert("s".into(), salt);
103                }
104            }
105        }
106        params.insert("v".into(), API_VERSION.into());
107        params.insert("c".into(), CLIENT_NAME.into());
108        params.insert("f".into(), "json".into());
109        Ok(params)
110    }
111
112    /// The auth params as a query string. Every value is URL-safe as built:
113    /// API keys are base64url.
114    pub fn query(&self) -> Result<String, SubsonicError> {
115        Ok(self
116            .params()?
117            .iter()
118            .map(|(k, v)| format!("{}={}", k, v))
119            .collect::<Vec<_>>()
120            .join("&"))
121    }
122
123    /// Build the streaming URL for a track (doesn't make a request).
124    pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
125        Ok(format!(
126            "{}/rest/stream?id={}&{}",
127            self.base_url,
128            track_id,
129            self.query()?
130        ))
131    }
132}
133
134/// Subsonic/Navidrome API client.
135///
136/// Holds two HTTP clients with different timeout semantics: `http` bounds a
137/// whole JSON request, which is right for small API responses read in one go;
138/// `downloader` bounds only connect and per-read stalls, so a large track on a
139/// slow link is never cut off for taking too long overall.
140pub struct SubsonicClient {
141    auth: SubsonicAuth,
142    http: reqwest::blocking::Client,
143    downloader: reqwest::blocking::Client,
144    /// Whether the server is answering downloads, as the last of them found.
145    outage: download::Outage,
146    /// Whether the server offers `reportPlayback`, once it has said.
147    playback_report: OnceLock<bool>,
148}
149
150/// What a playback report says the player is doing.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum PlaybackReportState {
153    Playing,
154    Paused,
155    Stopped,
156}
157
158impl PlaybackReportState {
159    fn as_str(self) -> &'static str {
160        match self {
161            Self::Playing => "playing",
162            Self::Paused => "paused",
163            Self::Stopped => "stopped",
164        }
165    }
166}
167
168impl SubsonicClient {
169    pub fn new(base_url: &str, username: &str, password: &str) -> Self {
170        Self::from_auth(SubsonicAuth::new(base_url, username, password))
171    }
172
173    pub fn from_auth(auth: SubsonicAuth) -> Self {
174        Self {
175            auth,
176            http: download::api_client().unwrap_or_else(|e| {
177                log::warn!("falling back to default HTTP client: {}", e);
178                reqwest::blocking::Client::new()
179            }),
180            downloader: download::download_client().unwrap_or_else(|e| {
181                log::warn!("falling back to default download client: {}", e);
182                reqwest::blocking::Client::new()
183            }),
184            outage: download::Outage::default(),
185            playback_report: OnceLock::new(),
186        }
187    }
188
189    /// Whether downloads from this server are waiting out an outage.
190    pub fn outage(&self) -> &download::Outage {
191        &self.outage
192    }
193
194    fn auth_params(&self) -> Result<HashMap<String, String>, SubsonicError> {
195        self.auth.params()
196    }
197
198    /// Make a GET request to a Subsonic API endpoint.
199    fn get(&self, endpoint: &str) -> Result<SubsonicResponse, SubsonicError> {
200        self.get_with_params(endpoint, &[])
201    }
202
203    fn get_with_params(
204        &self,
205        endpoint: &str,
206        extra: &[(&str, &str)],
207    ) -> Result<SubsonicResponse, SubsonicError> {
208        let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
209        let mut params = self.auth_params()?;
210        for (k, v) in extra {
211            params.insert((*k).to_string(), (*v).to_string());
212        }
213
214        let resp: SubsonicResponseWrapper = self.http.get(&url).query(&params).send()?.json()?;
215        resp.subsonic_response.ok()
216    }
217
218    /// Detect a Subsonic error returned from an endpoint that should have sent
219    /// binary data.
220    ///
221    /// Subsonic signals failure with HTTP 200 and a JSON or XML error body, so
222    /// checking the status code proves nothing here — without this, an error
223    /// response gets written to disk as if it were audio.
224    fn reject_error_body(resp: &reqwest::blocking::Response) -> Result<(), SubsonicError> {
225        let is_document = resp
226            .headers()
227            .get(reqwest::header::CONTENT_TYPE)
228            .and_then(|v| v.to_str().ok())
229            .is_some_and(|ct| ct.contains("json") || ct.contains("xml"));
230        if is_document {
231            return Err(SubsonicError::BadResponse);
232        }
233        if !resp.status().is_success() {
234            return Err(SubsonicError::BadResponse);
235        }
236        Ok(())
237    }
238
239    /// Fetch cover art bytes for a song or album ID.
240    ///
241    /// Returns the raw image rather than a parsed response — `getCoverArt`
242    /// answers with image data, not JSON, so it can't go through `get()`.
243    /// `size` requests a square thumbnail; omit it for the original.
244    pub fn get_cover_art(&self, id: &str, size: Option<u32>) -> Result<Vec<u8>, SubsonicError> {
245        let url = format!("{}/rest/getCoverArt", self.base_url());
246        let mut params = self.auth_params()?;
247        params.insert("id".into(), id.to_string());
248        if let Some(px) = size {
249            params.insert("size".into(), px.to_string());
250        }
251
252        let resp = self.http.get(&url).query(&params).send()?;
253        Self::reject_error_body(&resp)?;
254        Ok(resp.bytes()?.to_vec())
255    }
256
257    /// Ping the server — verify connection and credentials.
258    pub fn ping(&self) -> Result<(), SubsonicError> {
259        self.get("ping")?;
260        Ok(())
261    }
262
263    /// Which server this is, by its OpenSubsonic `type`. One round trip.
264    pub fn server_type(&self) -> Result<Option<String>, SubsonicError> {
265        Ok(self.get("ping")?.server_type)
266    }
267
268    /// What this server says it is and which OpenSubsonic extensions it
269    /// offers: `ping`, then `getOpenSubsonicExtensions` when it speaks
270    /// OpenSubsonic. A server that does not has no extensions to list.
271    pub fn profile(&self) -> Result<crate::remote::profile::ServerProfile, SubsonicError> {
272        let ping = self.get("ping")?;
273        let extensions = if ping.open_subsonic {
274            self.get("getOpenSubsonicExtensions")
275                .ok()
276                .and_then(|r| r.open_subsonic_extensions)
277                .unwrap_or_default()
278                .into_iter()
279                .map(|e| (e.name, e.versions))
280                .collect()
281        } else {
282            Vec::new()
283        };
284        Ok(crate::remote::profile::ServerProfile {
285            kind: ping.server_type,
286            version: ping.server_version,
287            open_subsonic: ping.open_subsonic,
288            extensions,
289        })
290    }
291
292    /// Get all artists (indexed).
293    pub fn get_artists(&self) -> Result<Vec<SubsonicArtist>, SubsonicError> {
294        let resp = self.get("getArtists")?;
295        let artists_data = resp.artists.ok_or(SubsonicError::BadResponse)?;
296        let mut all = Vec::new();
297        for index in artists_data.index {
298            all.extend(index.artist);
299        }
300        Ok(all)
301    }
302
303    /// Get an album by ID, including its tracks.
304    pub fn get_album(&self, id: &str) -> Result<SubsonicAlbumFull, SubsonicError> {
305        let resp = self.get_with_params("getAlbum", &[("id", id)])?;
306        resp.album.ok_or(SubsonicError::BadResponse)
307    }
308
309    /// Get a paginated list of albums.
310    pub fn get_album_list(
311        &self,
312        list_type: &str,
313        size: u32,
314        offset: u32,
315    ) -> Result<Vec<SubsonicAlbum>, SubsonicError> {
316        let size_str = size.to_string();
317        let offset_str = offset.to_string();
318        let resp = self.get_with_params(
319            "getAlbumList2",
320            &[
321                ("type", list_type),
322                ("size", &size_str),
323                ("offset", &offset_str),
324            ],
325        )?;
326        Ok(resp.album_list2.map(|al| al.album).unwrap_or_default())
327    }
328
329    /// Build the streaming URL for a track (doesn't make a request).
330    pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
331        self.auth.stream_url(track_id)
332    }
333
334    /// Stream URL without auth params — safe for database storage.
335    pub fn stream_url_template(&self, track_id: &str) -> String {
336        format!("{}/rest/stream?id={}", self.auth.base_url, track_id)
337    }
338
339    /// Download a track to a local path.
340    pub fn download(&self, track_id: &str, dest: &Path) -> Result<(), SubsonicError> {
341        self.fetch_to_file("download", track_id, dest, None, |_, _| {})
342    }
343
344    /// Download a track with progress reporting.
345    ///
346    /// The callback receives `(bytes_downloaded, total_bytes)`; total is 0 when
347    /// the server sends no Content-Length, and the count restarts from zero if
348    /// an attempt is retried. `dest` only appears once the file is complete.
349    ///
350    /// A server that is not answering is waited out, however long that takes,
351    /// until `cancelled` says the track is no longer wanted.
352    pub fn download_with_progress(
353        &self,
354        track_id: &str,
355        dest: &Path,
356        cancelled: &dyn Fn() -> bool,
357        on_progress: impl Fn(u64, u64),
358    ) -> Result<(), SubsonicError> {
359        let patience = download::Patience {
360            outage: &self.outage,
361            cancelled,
362        };
363        self.fetch_to_file("download", track_id, dest, Some(patience), on_progress)
364    }
365
366    /// Fetch a track through `/rest/stream` instead of `/rest/download`.
367    ///
368    /// `download` returns the untranscoded original and is what library sync
369    /// wants from Navidrome. koan's own server implements only `stream`, so
370    /// that is how the remote bridge pulls audio from a `koan serve` instance.
371    pub fn stream_to_file(
372        &self,
373        track_id: &str,
374        dest: &Path,
375        on_progress: impl Fn(u64, u64),
376    ) -> Result<(), SubsonicError> {
377        self.fetch_to_file("stream", track_id, dest, None, on_progress)
378    }
379
380    fn fetch_to_file(
381        &self,
382        endpoint: &str,
383        track_id: &str,
384        dest: &Path,
385        patience: Option<download::Patience<'_>>,
386        on_progress: impl Fn(u64, u64),
387    ) -> Result<(), SubsonicError> {
388        let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
389        download::download_with_retries(
390            dest,
391            download::DEFAULT_ATTEMPTS,
392            patience,
393            || {
394                // Fresh auth params per attempt — the salt must not be replayed.
395                let mut params = self
396                    .auth_params()
397                    .map_err(|e| download::DownloadError::Request(e.to_string()))?;
398                params.insert("id".into(), track_id.to_string());
399                Ok(self.downloader.get(&url).query(&params))
400            },
401            on_progress,
402        )?;
403        Ok(())
404    }
405
406    /// One page of every song on the server, `size` from `offset`.
407    ///
408    /// An empty `search3` query lists the whole library on OpenSubsonic
409    /// servers (Navidrome, koan). Older servers answer it with nothing or an
410    /// error, which is the caller's cue to walk albums one at a time instead.
411    pub fn all_songs_page(
412        &self,
413        size: u32,
414        offset: u32,
415    ) -> Result<Vec<SubsonicSong>, SubsonicError> {
416        let size = size.to_string();
417        let offset = offset.to_string();
418        let resp = self.get_with_params(
419            "search3",
420            &[
421                ("query", ""),
422                ("artistCount", "0"),
423                ("albumCount", "0"),
424                ("songCount", &size),
425                ("songOffset", &offset),
426            ],
427        )?;
428        Ok(resp.search_result3.map(|r| r.song).unwrap_or_default())
429    }
430
431    /// How many songs the server says it has, from `getScanStatus`. `None`
432    /// where the server does not count.
433    pub fn song_count(&self) -> Result<Option<u64>, SubsonicError> {
434        let resp = self.get("getScanStatus")?;
435        Ok(resp.scan_status.and_then(|s| s.count))
436    }
437
438    /// When the server's library last changed, in milliseconds, as
439    /// `getIndexes` reports it. Asked `since` the version last seen, so a
440    /// library that has not moved answers with the timestamp alone. `None`
441    /// where the server gives none.
442    pub fn library_modified(&self, since: Option<i64>) -> Result<Option<i64>, SubsonicError> {
443        let since = since.map(|v| v.to_string());
444        let params: Vec<(&str, &str)> = since
445            .as_deref()
446            .map(|v| vec![("ifModifiedSince", v)])
447            .unwrap_or_default();
448        let resp = self.get_with_params("getIndexes", &params)?;
449        Ok(resp.indexes.and_then(|i| i.last_modified))
450    }
451
452    /// Search for tracks/albums/artists.
453    pub fn search(&self, query: &str) -> Result<SubsonicSearchResult, SubsonicError> {
454        let resp = self.get_with_params("search3", &[("query", query)])?;
455        Ok(resp.search_result3.unwrap_or_default())
456    }
457
458    /// Scrobble a play that counts, dated to `heard_at_ms` (ms since the
459    /// epoch the listen began).
460    pub fn scrobble(&self, track_id: &str, heard_at_ms: u64) -> Result<(), SubsonicError> {
461        let at = heard_at_ms.to_string();
462        self.get_with_params(
463            "scrobble",
464            &[("id", track_id), ("submission", "true"), ("time", &at)],
465        )?;
466        Ok(())
467    }
468
469    /// Tell the server where playback of a track stands, so its Now Playing
470    /// follows pauses, seeks and stops.
471    ///
472    /// Uses OpenSubsonic's `reportPlayback` where the server offers it, always
473    /// with `ignoreScrobble`: koan decides what counts as a play and scrobbles
474    /// it itself. Elsewhere only `playing` can be said, as a now-playing
475    /// `scrobble` from `position` (seconds, as Navidrome reads it); a pause or
476    /// a stop has no equivalent there and is not sent.
477    pub fn report_playback(
478        &self,
479        track_id: &str,
480        state: PlaybackReportState,
481        position_ms: u64,
482    ) -> Result<(), SubsonicError> {
483        if self.supports_playback_report() {
484            let position = position_ms.to_string();
485            self.get_with_params(
486                "reportPlayback",
487                &[
488                    ("mediaId", track_id),
489                    ("mediaType", "song"),
490                    ("positionMs", &position),
491                    ("state", state.as_str()),
492                    ("ignoreScrobble", "true"),
493                ],
494            )?;
495            return Ok(());
496        }
497        if state == PlaybackReportState::Playing {
498            let position = (position_ms / 1000).to_string();
499            self.get_with_params(
500                "scrobble",
501                &[
502                    ("id", track_id),
503                    ("submission", "false"),
504                    ("position", &position),
505                ],
506            )?;
507        }
508        Ok(())
509    }
510
511    /// Whether the server advertises the `playbackReport` extension. Asked
512    /// once per client; any answer, including an error, is kept. Only a
513    /// failure to reach the server at all is asked again next time.
514    fn supports_playback_report(&self) -> bool {
515        if let Some(&known) = self.playback_report.get() {
516            return known;
517        }
518        let supported = match self.get("getOpenSubsonicExtensions") {
519            Ok(resp) => resp.has_extension(PLAYBACK_REPORT_EXTENSION),
520            Err(SubsonicError::Http(e)) if e.is_connect() || e.is_timeout() => return false,
521            Err(_) => false,
522        };
523        *self.playback_report.get_or_init(|| supported)
524    }
525
526    /// Star (favourite) a track on the server.
527    pub fn star(&self, track_id: &str) -> Result<(), SubsonicError> {
528        self.get_with_params("star", &[("id", track_id)])?;
529        Ok(())
530    }
531
532    /// Unstar (unfavourite) a track on the server.
533    pub fn unstar(&self, track_id: &str) -> Result<(), SubsonicError> {
534        self.get_with_params("unstar", &[("id", track_id)])?;
535        Ok(())
536    }
537
538    /// Everything the server has starred: songs, albums and artists.
539    ///
540    /// Subsonic returns all three from one call, so asking for songs alone
541    /// leaves a starred album invisible to us for no saving.
542    pub fn get_starred_all(&self) -> Result<SubsonicStarred, SubsonicError> {
543        let resp = self.get("getStarred2")?;
544        Ok(resp.starred2.unwrap_or_default())
545    }
546
547    /// Star an album. Subsonic keys this off a different parameter to a song —
548    /// `id` would be read as a track and silently star nothing.
549    pub fn star_album(&self, album_id: &str) -> Result<(), SubsonicError> {
550        self.get_with_params("star", &[("albumId", album_id)])?;
551        Ok(())
552    }
553
554    pub fn unstar_album(&self, album_id: &str) -> Result<(), SubsonicError> {
555        self.get_with_params("unstar", &[("albumId", album_id)])?;
556        Ok(())
557    }
558
559    pub fn star_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
560        self.get_with_params("star", &[("artistId", artist_id)])?;
561        Ok(())
562    }
563
564    pub fn unstar_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
565        self.get_with_params("unstar", &[("artistId", artist_id)])?;
566        Ok(())
567    }
568
569    /// Create a sharing link for one or more resources (songs, albums, etc).
570    /// Returns the created share including its ID which forms the public URL.
571    pub fn create_share(
572        &self,
573        ids: &[&str],
574        description: Option<&str>,
575    ) -> Result<SubsonicShare, SubsonicError> {
576        let url = format!("{}/rest/createShare", self.auth.base_url);
577        let mut params = self.auth_params()?;
578        if let Some(desc) = description {
579            params.insert("description".into(), desc.to_string());
580        }
581
582        // Subsonic API takes `id` as a repeated param for multiple resources.
583        let mut query: Vec<(String, String)> = params.into_iter().collect();
584        for id in ids {
585            query.push(("id".into(), (*id).to_string()));
586        }
587
588        let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
589
590        let inner = resp.subsonic_response;
591        if inner.status != "ok" {
592            if let Some(err) = inner.error {
593                return Err(SubsonicError::Api {
594                    code: err.code,
595                    message: err.message,
596                });
597            }
598            return Err(SubsonicError::BadResponse);
599        }
600
601        inner
602            .shares
603            .and_then(|s| s.share.into_iter().next())
604            .ok_or(SubsonicError::BadResponse)
605    }
606
607    /// Get similar songs for a track (Subsonic getSimilarSongs2 endpoint).
608    /// Returns up to `count` similar songs based on the server's algorithm.
609    pub fn get_similar_songs(
610        &self,
611        song_id: &str,
612        count: usize,
613    ) -> Result<Vec<SubsonicSong>, SubsonicError> {
614        let count_str = count.to_string();
615        let resp = self.get_with_params(
616            "getSimilarSongs2",
617            &[("id", song_id), ("count", &count_str)],
618        )?;
619        Ok(resp.similar_songs2.and_then(|s| s.song).unwrap_or_default())
620    }
621
622    // --- Playlists ---------------------------------------------------------
623
624    /// Every playlist the server will show this user, without their contents.
625    pub fn get_playlists(&self) -> Result<Vec<SubsonicPlaylist>, SubsonicError> {
626        let resp = self.get("getPlaylists")?;
627        Ok(resp.playlists.map(|p| p.playlist).unwrap_or_default())
628    }
629
630    /// One playlist, with its songs in order.
631    pub fn get_playlist(&self, id: &str) -> Result<SubsonicPlaylistFull, SubsonicError> {
632        let resp = self.get_with_params("getPlaylist", &[("id", id)])?;
633        resp.playlist.ok_or(SubsonicError::BadResponse)
634    }
635
636    /// Create a playlist, or replace an existing one's contents wholesale.
637    ///
638    /// `createPlaylist` is the only Subsonic call that can set a playlist's
639    /// order: `updatePlaylist` appends and removes by index, which cannot
640    /// express a reorder. Passing `playlist_id` turns this into "these songs,
641    /// in this order, from now on", which is exactly what koan has after any
642    /// edit — so every push takes this path and there is one way for the two
643    /// sides to disagree instead of five.
644    pub fn create_playlist(
645        &self,
646        playlist_id: Option<&str>,
647        name: &str,
648        song_ids: &[String],
649    ) -> Result<Option<SubsonicPlaylistFull>, SubsonicError> {
650        let url = format!("{}/rest/createPlaylist", self.auth.base_url);
651        let mut params = self.auth_params()?;
652        match playlist_id {
653            Some(id) => {
654                params.insert("playlistId".into(), id.to_string());
655                // Navidrome keeps the stored name when updating, but a rename
656                // that happened offline has to travel somehow.
657                params.insert("name".into(), name.to_string());
658            }
659            None => {
660                params.insert("name".into(), name.to_string());
661            }
662        }
663
664        // Repeated `songId`, in order — that order is the playlist.
665        let mut query: Vec<(String, String)> = params.into_iter().collect();
666        for id in song_ids {
667            query.push(("songId".into(), id.clone()));
668        }
669
670        let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
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        // Servers before 1.14.0 answer with an empty body, so an absent
682        // playlist here is not an error — only a caller that needed the new id
683        // has a problem, and it says so itself.
684        Ok(inner.playlist)
685    }
686
687    /// Change what can be changed without touching the song list.
688    pub fn update_playlist(
689        &self,
690        id: &str,
691        name: Option<&str>,
692        comment: Option<&str>,
693        public: Option<bool>,
694    ) -> Result<(), SubsonicError> {
695        let mut extra: Vec<(&str, String)> = vec![("playlistId", id.to_string())];
696        if let Some(name) = name {
697            extra.push(("name", name.to_string()));
698        }
699        if let Some(comment) = comment {
700            extra.push(("comment", comment.to_string()));
701        }
702        if let Some(public) = public {
703            extra.push(("public", public.to_string()));
704        }
705        let borrowed: Vec<(&str, &str)> = extra.iter().map(|(k, v)| (*k, v.as_str())).collect();
706        self.get_with_params("updatePlaylist", &borrowed)?;
707        Ok(())
708    }
709
710    pub fn delete_playlist(&self, id: &str) -> Result<(), SubsonicError> {
711        self.get_with_params("deletePlaylist", &[("id", id)])?;
712        Ok(())
713    }
714
715    // -- Accounts: koan servers only, and only for an admin --
716
717    pub fn koan_users(&self) -> Result<Vec<KoanUser>, SubsonicError> {
718        Ok(self
719            .get("koanUsers")?
720            .users
721            .map(|u| u.user)
722            .unwrap_or_default())
723    }
724
725    /// `role` is `admin`, `user` or `readonly`.
726    pub fn koan_create_user(
727        &self,
728        username: &str,
729        role: &str,
730    ) -> Result<KoanInvite, SubsonicError> {
731        self.get_with_params("koanCreateUser", &[("username", username), ("role", role)])?
732            .invite
733            .ok_or(SubsonicError::BadResponse)
734    }
735
736    /// With `reset`, the account gets a new password, which comes back with the
737    /// invite, and its devices sign out.
738    pub fn koan_invite(&self, username: &str, reset: bool) -> Result<KoanInvite, SubsonicError> {
739        let reset = if reset { "true" } else { "false" };
740        self.get_with_params("koanInvite", &[("username", username), ("reset", reset)])?
741            .invite
742            .ok_or(SubsonicError::BadResponse)
743    }
744
745    /// Revoke the API key this client signs in with (`koanRevokeKey`).
746    pub fn koan_revoke_own_key(&self) -> Result<(), SubsonicError> {
747        self.get("koanRevokeKey")?;
748        Ok(())
749    }
750
751    pub fn koan_set_user_role(&self, username: &str, role: &str) -> Result<(), SubsonicError> {
752        self.get_with_params("koanSetUserRole", &[("username", username), ("role", role)])?;
753        Ok(())
754    }
755
756    pub fn koan_delete_user(&self, username: &str) -> Result<(), SubsonicError> {
757        self.get_with_params("koanDeleteUser", &[("username", username)])?;
758        Ok(())
759    }
760
761    /// Have the server hand `command` (a link command, as JSON) to the device
762    /// `to` on this account: a koan extension, `koanDevices`.
763    pub fn koan_command(&self, to: &str, command: &str) -> Result<(), SubsonicError> {
764        self.get_with_params("koanCommand", &[("to", to), ("command", command)])?;
765        Ok(())
766    }
767
768    pub fn auth(&self) -> &SubsonicAuth {
769        &self.auth
770    }
771
772    /// The configured server base URL (for constructing share links etc).
773    pub fn base_url(&self) -> &str {
774        &self.auth.base_url
775    }
776}
777
778// --- Response types ---
779
780#[derive(Debug, Deserialize)]
781struct SubsonicResponseWrapper {
782    #[serde(rename = "subsonic-response")]
783    subsonic_response: SubsonicResponse,
784}
785
786#[derive(Debug, Deserialize)]
787#[serde(rename_all = "camelCase")]
788struct SubsonicResponse {
789    status: String,
790    /// The OpenSubsonic server name ("navidrome", "koan"); absent on servers
791    /// that predate OpenSubsonic.
792    #[serde(rename = "type")]
793    server_type: Option<String>,
794    server_version: Option<String>,
795    #[serde(default)]
796    open_subsonic: bool,
797    open_subsonic_extensions: Option<Vec<SubsonicExtension>>,
798    error: Option<SubsonicApiError>,
799    artists: Option<SubsonicArtists>,
800    album: Option<SubsonicAlbumFull>,
801    album_list2: Option<SubsonicAlbumList>,
802    search_result3: Option<SubsonicSearchResult>,
803    starred2: Option<SubsonicStarred>,
804    shares: Option<SubsonicShares>,
805    similar_songs2: Option<SubsonicSimilarSongs>,
806    playlists: Option<SubsonicPlaylists>,
807    playlist: Option<SubsonicPlaylistFull>,
808    scan_status: Option<SubsonicScanStatus>,
809    indexes: Option<SubsonicIndexes>,
810    users: Option<KoanUsers>,
811    invite: Option<KoanInvite>,
812    join: Option<KoanJoined>,
813}
814
815#[derive(Debug, Deserialize)]
816struct KoanUsers {
817    #[serde(default)]
818    user: Vec<KoanUser>,
819}
820
821/// An account on a koan server, as its admins see it.
822#[derive(Debug, Clone, Deserialize)]
823pub struct KoanUser {
824    pub username: String,
825    pub role: String,
826}
827
828/// What a koan server hands back for an invite: the token the link carries,
829/// and the password when one was just made. The link is built from the
830/// address the client already reaches the server at.
831#[derive(Debug, Clone, Deserialize)]
832pub struct KoanInvite {
833    pub username: String,
834    pub token: String,
835    pub password: Option<String>,
836}
837
838/// An invite redeemed: the account it was for and the API key made for this
839/// device.
840#[derive(Debug, Clone, Deserialize)]
841#[serde(rename_all = "camelCase")]
842pub struct KoanJoined {
843    pub username: String,
844    pub api_key: String,
845}
846
847/// Trade a koan invite token for an API key (`koanJoin`), naming the key for
848/// `device`. Unauthenticated: the token is the credential.
849pub fn redeem_invite(
850    base_url: &str,
851    token: &str,
852    device: &str,
853) -> Result<KoanJoined, SubsonicError> {
854    let url = format!("{}/rest/koanJoin", base_url.trim_end_matches('/'));
855    let resp: SubsonicResponseWrapper = download::api_client()?
856        .get(&url)
857        .query(&[
858            ("invite", token),
859            ("name", device),
860            ("v", API_VERSION),
861            ("c", CLIENT_NAME),
862            ("f", "json"),
863        ])
864        .send()?
865        .json()?;
866    resp.subsonic_response
867        .ok()?
868        .join
869        .ok_or(SubsonicError::BadResponse)
870}
871
872impl SubsonicResponse {
873    fn ok(self) -> Result<Self, SubsonicError> {
874        if self.status == "ok" {
875            return Ok(self);
876        }
877        Err(self
878            .error
879            .map_or(SubsonicError::BadResponse, |err| SubsonicError::Api {
880                code: err.code,
881                message: err.message,
882            }))
883    }
884
885    fn has_extension(&self, name: &str) -> bool {
886        self.open_subsonic_extensions
887            .iter()
888            .flatten()
889            .any(|ext| ext.name == name)
890    }
891}
892
893#[derive(Debug, Deserialize)]
894struct SubsonicExtension {
895    name: String,
896    #[serde(default)]
897    versions: Vec<i64>,
898}
899
900#[derive(Debug, Deserialize)]
901#[serde(rename_all = "camelCase")]
902struct SubsonicIndexes {
903    last_modified: Option<i64>,
904}
905
906#[derive(Debug, Deserialize)]
907struct SubsonicScanStatus {
908    count: Option<u64>,
909}
910
911#[derive(Debug, Deserialize)]
912struct SubsonicApiError {
913    code: i32,
914    message: String,
915}
916
917#[derive(Debug, Deserialize)]
918struct SubsonicArtists {
919    index: Vec<SubsonicArtistIndex>,
920}
921
922#[derive(Debug, Deserialize)]
923struct SubsonicArtistIndex {
924    artist: Vec<SubsonicArtist>,
925}
926
927#[derive(Debug, Clone, Deserialize)]
928#[serde(rename_all = "camelCase")]
929pub struct SubsonicArtist {
930    pub id: String,
931    pub name: String,
932    pub album_count: Option<i32>,
933    // OpenSubsonic. Both arrive in `getArtists`, so keeping them costs no
934    // extra request.
935    #[serde(default, deserialize_with = "non_empty")]
936    pub music_brainz_id: Option<String>,
937    #[serde(default, deserialize_with = "non_empty")]
938    pub sort_name: Option<String>,
939}
940
941#[derive(Debug, Clone, Deserialize)]
942#[serde(rename_all = "camelCase")]
943pub struct SubsonicAlbum {
944    pub id: String,
945    pub name: String,
946    pub artist: Option<String>,
947    pub artist_id: Option<String>,
948    pub song_count: Option<i32>,
949    /// Seconds, summed over the album's songs.
950    #[serde(default)]
951    pub duration: Option<i64>,
952    pub year: Option<i32>,
953    #[serde(default, deserialize_with = "non_empty")]
954    pub genre: Option<String>,
955    pub created: Option<String>,
956    // OpenSubsonic. All of these arrive in `getAlbumList2`, which the sync
957    // already pages through.
958    #[serde(default, deserialize_with = "non_empty")]
959    pub music_brainz_id: Option<String>,
960    #[serde(default, deserialize_with = "non_empty")]
961    pub sort_name: Option<String>,
962    #[serde(default)]
963    pub record_labels: Vec<SubsonicName>,
964}
965
966/// A bare `{"name": "..."}` object. The server uses this shape for record
967/// labels, genres and moods alike.
968#[derive(Debug, Clone, Deserialize)]
969pub struct SubsonicName {
970    pub name: String,
971}
972
973#[derive(Debug, Clone, Deserialize)]
974#[serde(rename_all = "camelCase")]
975pub struct SubsonicAlbumFull {
976    pub id: String,
977    pub name: String,
978    pub artist: Option<String>,
979    pub artist_id: Option<String>,
980    pub year: Option<i32>,
981    #[serde(default, deserialize_with = "non_empty")]
982    pub genre: Option<String>,
983    pub song_count: Option<i32>,
984    pub created: Option<String>,
985    #[serde(default, deserialize_with = "non_empty")]
986    pub music_brainz_id: Option<String>,
987    #[serde(default, deserialize_with = "non_empty")]
988    pub sort_name: Option<String>,
989    #[serde(default)]
990    pub record_labels: Vec<SubsonicName>,
991    #[serde(default)]
992    pub song: Vec<SubsonicSong>,
993}
994
995#[derive(Debug, Clone, Deserialize)]
996#[serde(rename_all = "camelCase")]
997pub struct SubsonicSong {
998    pub id: String,
999    pub title: String,
1000    pub album: Option<String>,
1001    pub artist: Option<String>,
1002    pub track: Option<i32>,
1003    pub disc_number: Option<i32>,
1004    pub year: Option<i32>,
1005    #[serde(default, deserialize_with = "non_empty")]
1006    pub genre: Option<String>,
1007    pub duration: Option<i64>,
1008    pub bit_rate: Option<i32>,
1009    pub suffix: Option<String>,
1010    pub content_type: Option<String>,
1011    /// Sent by a koan server only; a suffix cannot tell ALAC from AAC.
1012    #[serde(default, deserialize_with = "non_empty")]
1013    pub codec: Option<String>,
1014    pub album_id: Option<String>,
1015    pub artist_id: Option<String>,
1016    // OpenSubsonic. Absent on a plain Subsonic server, which is why they are
1017    // Options rather than defaults — a missing sample rate is not 0 Hz.
1018    pub sampling_rate: Option<i32>,
1019    pub bit_depth: Option<i32>,
1020    pub channel_count: Option<i32>,
1021    #[serde(default, deserialize_with = "non_empty")]
1022    pub music_brainz_id: Option<String>,
1023}
1024
1025#[derive(Debug, Deserialize)]
1026struct SubsonicAlbumList {
1027    #[serde(default)]
1028    album: Vec<SubsonicAlbum>,
1029}
1030
1031#[derive(Debug, Default, Deserialize)]
1032pub struct SubsonicSearchResult {
1033    #[serde(default)]
1034    pub artist: Vec<SubsonicArtist>,
1035    #[serde(default)]
1036    pub album: Vec<SubsonicAlbum>,
1037    #[serde(default)]
1038    pub song: Vec<SubsonicSong>,
1039}
1040
1041#[derive(Debug, Default, Deserialize)]
1042pub struct SubsonicStarred {
1043    #[serde(default)]
1044    pub song: Vec<SubsonicSong>,
1045    #[serde(default)]
1046    pub album: Vec<SubsonicAlbum>,
1047    #[serde(default)]
1048    pub artist: Vec<SubsonicArtist>,
1049}
1050
1051#[derive(Debug, Deserialize)]
1052pub struct SubsonicSimilarSongs {
1053    pub song: Option<Vec<SubsonicSong>>,
1054}
1055
1056#[derive(Debug, Default, Deserialize)]
1057struct SubsonicPlaylists {
1058    #[serde(default)]
1059    playlist: Vec<SubsonicPlaylist>,
1060}
1061
1062/// A playlist as the server describes it, without its songs.
1063#[derive(Debug, Clone, Deserialize)]
1064#[serde(rename_all = "camelCase")]
1065pub struct SubsonicPlaylist {
1066    pub id: String,
1067    pub name: String,
1068    pub comment: Option<String>,
1069    pub owner: Option<String>,
1070    #[serde(default)]
1071    pub public: bool,
1072    pub song_count: Option<i64>,
1073    pub duration: Option<i64>,
1074    pub created: Option<String>,
1075    pub changed: Option<String>,
1076}
1077
1078#[derive(Debug, Clone, Deserialize)]
1079#[serde(rename_all = "camelCase")]
1080pub struct SubsonicPlaylistFull {
1081    #[serde(flatten)]
1082    pub playlist: SubsonicPlaylist,
1083    #[serde(default)]
1084    pub entry: Vec<SubsonicSong>,
1085}
1086
1087#[derive(Debug, Deserialize)]
1088struct SubsonicShares {
1089    #[serde(default)]
1090    share: Vec<SubsonicShare>,
1091}
1092
1093#[derive(Debug, Clone, Deserialize)]
1094#[serde(rename_all = "camelCase")]
1095pub struct SubsonicShare {
1096    pub id: String,
1097    pub url: Option<String>,
1098    pub description: Option<String>,
1099    pub username: Option<String>,
1100    pub created: Option<String>,
1101    pub expires: Option<String>,
1102    pub visit_count: Option<i64>,
1103}
1104
1105/// An empty string as absent. OpenSubsonic servers send every field they
1106/// support, empty where there is no value — koan's own sends
1107/// `musicBrainzId: ""` for an untagged track. Kept as `Some("")`, that id
1108/// would match every other untagged track: the MusicBrainz dedup would pair
1109/// unrelated tracks on it, scanning the whole table per insert to do so, and
1110/// album enrichment would write `""` over a missing id.
1111fn non_empty<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
1112    Ok(Option::<String>::deserialize(d)?.filter(|s| !s.is_empty()))
1113}
1114
1115/// Generate a random hex salt string for Subsonic auth.
1116///
1117/// The salt goes on the wire next to `md5(password + salt)`, so it has to be
1118/// unpredictable — a clock- or counter-derived fallback would make the token
1119/// precomputable from a captured exchange. A request without OS entropy fails
1120/// rather than authenticating weakly.
1121fn random_salt() -> Result<String, getrandom::Error> {
1122    let mut buf = [0u8; 12];
1123    getrandom::fill(&mut buf)?;
1124    Ok(buf.iter().map(|b| format!("{:02x}", b)).collect())
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129    use super::*;
1130
1131    fn response(json: &str) -> SubsonicResponse {
1132        serde_json::from_str::<SubsonicResponseWrapper>(json)
1133            .unwrap()
1134            .subsonic_response
1135    }
1136
1137    #[test]
1138    fn http_errors_leave_the_signed_url_out() {
1139        // Nothing listens on port 1, so this fails to connect.
1140        let e = reqwest::blocking::get("http://127.0.0.1:1/rest/stream?u=owner&p=enc:736563726574")
1141            .unwrap_err();
1142        assert!(e.to_string().contains("enc:736563726574"));
1143        let shown = SubsonicError::from(e).to_string();
1144        assert!(!shown.contains("enc:"), "{shown}");
1145        let e = reqwest::blocking::get("http://127.0.0.1:1/rest/stream?p=enc:736563726574")
1146            .unwrap_err();
1147        assert!(!DownloadError::from(e).to_string().contains("enc:"));
1148    }
1149
1150    #[test]
1151    fn playback_report_is_read_from_the_advertised_extensions() {
1152        let navidrome = response(
1153            r#"{"subsonic-response": {
1154                "status": "ok", "version": "1.16.1", "type": "navidrome",
1155                "openSubsonic": true,
1156                "openSubsonicExtensions": [
1157                    {"name": "transcodeOffset", "versions": [1]},
1158                    {"name": "playbackReport", "versions": [1]}
1159                ]
1160            }}"#,
1161        );
1162        assert!(navidrome.has_extension(PLAYBACK_REPORT_EXTENSION));
1163
1164        let without = response(
1165            r#"{"subsonic-response": {
1166                "status": "ok", "version": "1.16.1",
1167                "openSubsonicExtensions": [{"name": "songLyrics", "versions": [1, 2]}]
1168            }}"#,
1169        );
1170        assert!(!without.has_extension(PLAYBACK_REPORT_EXTENSION));
1171
1172        let plain = response(r#"{"subsonic-response": {"status": "ok", "version": "1.16.1"}}"#);
1173        assert!(!plain.has_extension(PLAYBACK_REPORT_EXTENSION));
1174    }
1175
1176    // --- SubsonicSong deserialization ---
1177
1178    #[test]
1179    fn test_deserialize_subsonic_song() {
1180        let json = r#"{
1181            "id": "42",
1182            "title": "Space Oddity",
1183            "album": "Space Oddity",
1184            "artist": "David Bowie",
1185            "track": 1,
1186            "discNumber": 1,
1187            "year": 1969,
1188            "genre": "Rock",
1189            "duration": 314,
1190            "bitRate": 320,
1191            "suffix": "mp3",
1192            "contentType": "audio/mpeg",
1193            "albumId": "7",
1194            "artistId": "3"
1195        }"#;
1196
1197        let song: SubsonicSong = serde_json::from_str(json).unwrap();
1198
1199        assert_eq!(song.id, "42");
1200        assert_eq!(song.title, "Space Oddity");
1201        assert_eq!(song.album.as_deref(), Some("Space Oddity"));
1202        assert_eq!(song.artist.as_deref(), Some("David Bowie"));
1203        assert_eq!(song.track, Some(1));
1204        assert_eq!(song.disc_number, Some(1));
1205        assert_eq!(song.year, Some(1969));
1206        assert_eq!(song.genre.as_deref(), Some("Rock"));
1207        assert_eq!(song.duration, Some(314));
1208        assert_eq!(song.bit_rate, Some(320));
1209        assert_eq!(song.suffix.as_deref(), Some("mp3"));
1210        assert_eq!(song.content_type.as_deref(), Some("audio/mpeg"));
1211        assert_eq!(song.album_id.as_deref(), Some("7"));
1212        assert_eq!(song.artist_id.as_deref(), Some("3"));
1213    }
1214
1215    /// An OpenSubsonic server reports the figures that make a track's quality
1216    /// legible. Ignoring them left every remote-only track with no sample rate
1217    /// and no bit depth at all.
1218    #[test]
1219    fn opensubsonic_quality_fields_are_read() {
1220        let json = r#"{
1221            "id": "000XtGC7jsWEbOjDsZi4Xw",
1222            "title": "Anguish",
1223            "suffix": "flac",
1224            "bitRate": 913,
1225            "samplingRate": 44100,
1226            "bitDepth": 16,
1227            "channelCount": 2
1228        }"#;
1229
1230        let song: SubsonicSong = serde_json::from_str(json).unwrap();
1231
1232        assert_eq!(song.sampling_rate, Some(44100));
1233        assert_eq!(song.bit_depth, Some(16));
1234        assert_eq!(song.channel_count, Some(2));
1235    }
1236
1237    /// A koan server names the codec, which the suffix cannot: ALAC and AAC
1238    /// are both m4a.
1239    #[test]
1240    fn a_koan_server_names_the_codec() {
1241        let json = r#"{"id": "1", "title": "Seawhite", "suffix": "m4a", "codec": "ALAC"}"#;
1242        let song: SubsonicSong = serde_json::from_str(json).unwrap();
1243        assert_eq!(song.codec.as_deref(), Some("ALAC"));
1244
1245        let json = r#"{"id": "1", "title": "Seawhite", "suffix": "m4a"}"#;
1246        let song: SubsonicSong = serde_json::from_str(json).unwrap();
1247        assert_eq!(song.codec, None);
1248    }
1249
1250    /// A plain Subsonic server omits them, and a missing sample rate is not
1251    /// 0 Hz — the fields have to stay absent rather than default.
1252    #[test]
1253    fn a_plain_subsonic_song_has_no_quality_figures() {
1254        let json = r#"{"id": "1", "title": "Track", "bitRate": 320}"#;
1255        let song: SubsonicSong = serde_json::from_str(json).unwrap();
1256
1257        assert_eq!(song.sampling_rate, None);
1258        assert_eq!(song.bit_depth, None);
1259        assert_eq!(song.channel_count, None);
1260    }
1261
1262    #[test]
1263    fn test_deserialize_subsonic_song_optional_fields_absent() {
1264        // Only the required fields (id, title) — all Option fields should be None.
1265        let json = r#"{"id": "99", "title": "Minimal Track"}"#;
1266
1267        let song: SubsonicSong = serde_json::from_str(json).unwrap();
1268
1269        assert_eq!(song.id, "99");
1270        assert_eq!(song.title, "Minimal Track");
1271        assert!(song.album.is_none());
1272        assert!(song.artist.is_none());
1273        assert!(song.track.is_none());
1274        assert!(song.disc_number.is_none());
1275        assert!(song.year.is_none());
1276        assert!(song.duration.is_none());
1277        assert!(song.bit_rate.is_none());
1278    }
1279
1280    // --- SubsonicAlbum deserialization ---
1281
1282    #[test]
1283    fn test_deserialize_album_list() {
1284        let json = r#"{
1285            "subsonic-response": {
1286                "status": "ok",
1287                "version": "1.16.1",
1288                "albumList2": {
1289                    "album": [
1290                        {
1291                            "id": "1",
1292                            "name": "Abbey Road",
1293                            "artist": "The Beatles",
1294                            "artistId": "10",
1295                            "songCount": 17,
1296                            "year": 1969,
1297                            "genre": "Rock",
1298                            "created": "2020-01-01T00:00:00"
1299                        },
1300                        {
1301                            "id": "2",
1302                            "name": "Led Zeppelin IV",
1303                            "artist": "Led Zeppelin",
1304                            "artistId": "11",
1305                            "songCount": 8,
1306                            "year": 1971,
1307                            "genre": "Hard Rock",
1308                            "created": "2020-01-02T00:00:00"
1309                        }
1310                    ]
1311                }
1312            }
1313        }"#;
1314
1315        let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
1316        let album_list = wrapper
1317            .subsonic_response
1318            .album_list2
1319            .expect("album_list2 should be present");
1320
1321        assert_eq!(album_list.album.len(), 2);
1322
1323        let first = &album_list.album[0];
1324        assert_eq!(first.id, "1");
1325        assert_eq!(first.name, "Abbey Road");
1326        assert_eq!(first.artist.as_deref(), Some("The Beatles"));
1327        assert_eq!(first.artist_id.as_deref(), Some("10"));
1328        assert_eq!(first.song_count, Some(17));
1329        assert_eq!(first.year, Some(1969));
1330
1331        let second = &album_list.album[1];
1332        assert_eq!(second.id, "2");
1333        assert_eq!(second.name, "Led Zeppelin IV");
1334        assert_eq!(second.song_count, Some(8));
1335    }
1336
1337    // --- SubsonicClient auth params ---
1338
1339    #[test]
1340    fn test_auth_params_format() {
1341        let client = SubsonicClient::new("http://localhost:4533", "alice", "secret");
1342        let params = client.auth_params().unwrap();
1343
1344        // Must contain exactly these six keys.
1345        assert!(params.contains_key("u"), "missing 'u' param");
1346        assert!(params.contains_key("t"), "missing 't' param");
1347        assert!(params.contains_key("s"), "missing 's' param");
1348        assert!(params.contains_key("v"), "missing 'v' param");
1349        assert!(params.contains_key("c"), "missing 'c' param");
1350        assert!(params.contains_key("f"), "missing 'f' param");
1351        assert_eq!(params.len(), 6);
1352
1353        assert_eq!(params["u"], "alice");
1354        assert_eq!(params["v"], "1.16.1");
1355        assert_eq!(params["c"], "koan");
1356        assert_eq!(params["f"], "json");
1357    }
1358
1359    #[test]
1360    fn test_auth_params_over_https_send_the_hex_password() {
1361        let client = SubsonicClient::new("https://koan.example", "alice", "hi");
1362        let params = client.auth_params().unwrap();
1363        assert_eq!(params["p"], "enc:6869");
1364        assert!(!params.contains_key("t") && !params.contains_key("s"));
1365    }
1366
1367    #[test]
1368    fn test_auth_params_with_an_api_key_name_no_user() {
1369        let auth = SubsonicAuth::with(
1370            "http://koan.example",
1371            "alice",
1372            Credential::ApiKey("k3y".into()),
1373        );
1374        let params = auth.params().unwrap();
1375        assert_eq!(params.get("apiKey").map(String::as_str), Some("k3y"));
1376        for absent in ["u", "p", "t", "s"] {
1377            assert!(!params.contains_key(absent), "{absent}");
1378        }
1379    }
1380
1381    #[test]
1382    fn test_auth_params_token_is_md5_of_password_plus_salt() {
1383        let client = SubsonicClient::new("http://localhost:4533", "bob", "letmein");
1384        let params = client.auth_params().unwrap();
1385
1386        let salt = &params["s"];
1387        let token = &params["t"];
1388
1389        // The token must equal md5(password + salt).
1390        let expected = format!("{:x}", md5::compute(format!("letmein{}", salt)));
1391        assert_eq!(token, &expected);
1392    }
1393
1394    #[test]
1395    fn test_auth_params_salt_is_different_each_call() {
1396        let client = SubsonicClient::new("http://localhost:4533", "user", "pass");
1397        let params1 = client.auth_params().unwrap();
1398        let params2 = client.auth_params().unwrap();
1399
1400        // Salts should differ across calls (random); tokens will differ too.
1401        // There is a negligible probability they collide — acceptable in tests.
1402        assert_ne!(params1["s"], params2["s"], "salt should be random per call");
1403    }
1404
1405    // --- stream_url ---
1406
1407    #[test]
1408    fn test_stream_url_has_auth() {
1409        let client = SubsonicClient::new("http://myserver:4533", "user", "pass");
1410        let url = client.stream_url("track-123").unwrap();
1411
1412        assert!(url.contains("track-123"), "url must include the track id");
1413        assert!(url.contains("u=user"), "url must include username param");
1414        assert!(url.contains("v=1.16.1"), "url must include api version");
1415        assert!(url.contains("c=koan"), "url must include client name");
1416        assert!(url.contains("f=json"), "url must include format param");
1417        assert!(url.contains("/rest/stream"), "url must target /rest/stream");
1418        assert!(
1419            url.starts_with("http://myserver:4533"),
1420            "url must use the configured base_url"
1421        );
1422    }
1423
1424    #[test]
1425    fn test_stream_url_base_url_trailing_slash_normalised() {
1426        // SubsonicClient::new strips trailing slashes from base_url.
1427        let client_with_slash = SubsonicClient::new("http://myserver:4533/", "u", "p");
1428        let client_no_slash = SubsonicClient::new("http://myserver:4533", "u", "p");
1429
1430        let url_with = client_with_slash.stream_url("1").unwrap();
1431        let url_without = client_no_slash.stream_url("1").unwrap();
1432
1433        // Both should produce the same path prefix (no double slash).
1434        assert!(
1435            url_with.contains("/rest/stream"),
1436            "should not have double slash"
1437        );
1438        assert!(!url_with.contains("//rest"), "should not have double slash");
1439        // Both base URLs normalise to the same path structure.
1440        assert_eq!(
1441            url_with.split('?').next(),
1442            url_without.split('?').next(),
1443            "path segment should be identical regardless of trailing slash"
1444        );
1445    }
1446
1447    // --- SubsonicAlbumFull deserialization ---
1448
1449    #[test]
1450    fn test_deserialize_album_full_with_songs() {
1451        let json = r#"{
1452            "id": "5",
1453            "name": "Kind of Blue",
1454            "artist": "Miles Davis",
1455            "artistId": "20",
1456            "year": 1959,
1457            "genre": "Jazz",
1458            "songCount": 5,
1459            "created": "2021-06-01T00:00:00",
1460            "song": [
1461                {"id": "101", "title": "So What"},
1462                {"id": "102", "title": "Freddie Freeloader"},
1463                {"id": "103", "title": "Blue in Green"}
1464            ]
1465        }"#;
1466
1467        let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1468
1469        assert_eq!(album.id, "5");
1470        assert_eq!(album.name, "Kind of Blue");
1471        assert_eq!(album.artist.as_deref(), Some("Miles Davis"));
1472        assert_eq!(album.year, Some(1959));
1473        assert_eq!(album.song.len(), 3);
1474        assert_eq!(album.song[0].title, "So What");
1475        assert_eq!(album.song[2].id, "103");
1476    }
1477
1478    #[test]
1479    fn test_deserialize_album_full_empty_song_list() {
1480        // When `song` key is absent, the #[serde(default)] should yield an empty Vec.
1481        let json = r#"{"id": "9", "name": "No Tracks Yet"}"#;
1482
1483        let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1484
1485        assert_eq!(album.id, "9");
1486        assert!(album.song.is_empty(), "song list should default to empty");
1487    }
1488
1489    // --- SubsonicSearchResult deserialization ---
1490
1491    #[test]
1492    fn test_deserialize_search_result_mixed() {
1493        let json = r#"{
1494            "artist": [{"id": "1", "name": "Artist One"}],
1495            "album":  [{"id": "2", "name": "Album One"}],
1496            "song":   [{"id": "3", "title": "Song One"}]
1497        }"#;
1498
1499        let result: SubsonicSearchResult = serde_json::from_str(json).unwrap();
1500
1501        assert_eq!(result.artist.len(), 1);
1502        assert_eq!(result.artist[0].name, "Artist One");
1503        assert_eq!(result.album.len(), 1);
1504        assert_eq!(result.album[0].name, "Album One");
1505        assert_eq!(result.song.len(), 1);
1506        assert_eq!(result.song[0].title, "Song One");
1507    }
1508
1509    #[test]
1510    fn empty_opensubsonic_ids_are_absent() {
1511        let json = r#"{"id": "1", "title": "T", "musicBrainzId": ""}"#;
1512        let song: SubsonicSong = serde_json::from_str(json).unwrap();
1513        assert_eq!(song.music_brainz_id, None);
1514
1515        let json = r#"{"id": "2", "name": "A", "musicBrainzId": "", "sortName": ""}"#;
1516        let album: SubsonicAlbum = serde_json::from_str(json).unwrap();
1517        assert_eq!((album.music_brainz_id, album.sort_name), (None, None));
1518
1519        let json = r#"{"id": "3", "name": "A", "musicBrainzId": "mb-1"}"#;
1520        let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1521        assert_eq!(album.music_brainz_id.as_deref(), Some("mb-1"));
1522        assert_eq!(album.sort_name, None);
1523    }
1524
1525    #[test]
1526    fn test_deserialize_scan_status_count() {
1527        let json = r#"{"subsonic-response":{"status":"ok","scanStatus":{"scanning":false,"count":49700}}}"#;
1528        let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
1529        let status = wrapper.subsonic_response.scan_status.unwrap();
1530        assert_eq!(status.count, Some(49_700));
1531    }
1532
1533    #[test]
1534    fn test_deserialize_search_result_defaults_to_empty() {
1535        // All three lists are #[serde(default)], so an empty object is valid.
1536        let result: SubsonicSearchResult = serde_json::from_str("{}").unwrap();
1537
1538        assert!(result.artist.is_empty());
1539        assert!(result.album.is_empty());
1540        assert!(result.song.is_empty());
1541    }
1542}