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