Skip to main content

koan_core/remote/
client.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use serde::Deserialize;
5use thiserror::Error;
6
7use super::download::{self, DownloadError};
8
9const API_VERSION: &str = "1.16.1";
10const CLIENT_NAME: &str = "koan";
11
12#[derive(Debug, Error)]
13pub enum SubsonicError {
14    #[error("http error: {0}")]
15    Http(#[from] reqwest::Error),
16    #[error("api error: {code} — {message}")]
17    Api { code: i32, message: String },
18    #[error("unexpected response format")]
19    BadResponse,
20    #[error("io error: {0}")]
21    Io(#[from] std::io::Error),
22    #[error("download error: {0}")]
23    Download(#[from] DownloadError),
24    #[error("entropy source unavailable: {0}")]
25    Entropy(#[from] getrandom::Error),
26}
27
28/// A Subsonic server and the credentials that sign requests to it.
29///
30/// Kept separate from `SubsonicClient` because constructing that builds two
31/// blocking `reqwest` clients, each carrying its own runtime — doing so from
32/// inside a tokio runtime panics. A caller that only needs a signed URL, such
33/// as koan's own Subsonic proxy, holds this instead.
34#[derive(Debug, Clone)]
35pub struct SubsonicAuth {
36    pub base_url: String,
37    pub username: String,
38    pub password: String,
39}
40
41impl SubsonicAuth {
42    pub fn new(base_url: &str, username: &str, password: &str) -> Self {
43        Self {
44            base_url: base_url.trim_end_matches('/').to_string(),
45            username: username.to_string(),
46            password: password.to_string(),
47        }
48    }
49
50    /// Build auth query params: u, t (token), s (salt), v, c, f.
51    fn params(&self) -> Result<HashMap<String, String>, SubsonicError> {
52        let salt = random_salt()?;
53
54        let token = format!("{:x}", md5::compute(format!("{}{}", self.password, salt)));
55
56        let mut params = HashMap::new();
57        params.insert("u".into(), self.username.clone());
58        params.insert("t".into(), token);
59        params.insert("s".into(), salt);
60        params.insert("v".into(), API_VERSION.into());
61        params.insert("c".into(), CLIENT_NAME.into());
62        params.insert("f".into(), "json".into());
63        Ok(params)
64    }
65
66    /// Build the streaming URL for a track (doesn't make a request).
67    pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
68        let query: String = self
69            .params()?
70            .iter()
71            .map(|(k, v)| format!("{}={}", k, v))
72            .collect::<Vec<_>>()
73            .join("&");
74        Ok(format!(
75            "{}/rest/stream?id={}&{}",
76            self.base_url, track_id, query
77        ))
78    }
79}
80
81/// Subsonic/Navidrome API client.
82///
83/// Holds two HTTP clients with different timeout semantics: `http` bounds a
84/// whole JSON request, which is right for small API responses read in one go;
85/// `downloader` bounds only connect and per-read stalls, so a large track on a
86/// slow link is never cut off for taking too long overall.
87pub struct SubsonicClient {
88    auth: SubsonicAuth,
89    http: reqwest::blocking::Client,
90    downloader: reqwest::blocking::Client,
91}
92
93impl SubsonicClient {
94    pub fn new(base_url: &str, username: &str, password: &str) -> Self {
95        Self::from_auth(SubsonicAuth::new(base_url, username, password))
96    }
97
98    pub fn from_auth(auth: SubsonicAuth) -> Self {
99        Self {
100            auth,
101            http: download::api_client().unwrap_or_else(|e| {
102                log::warn!("falling back to default HTTP client: {}", e);
103                reqwest::blocking::Client::new()
104            }),
105            downloader: download::download_client().unwrap_or_else(|e| {
106                log::warn!("falling back to default download client: {}", e);
107                reqwest::blocking::Client::new()
108            }),
109        }
110    }
111
112    fn auth_params(&self) -> Result<HashMap<String, String>, SubsonicError> {
113        self.auth.params()
114    }
115
116    /// Make a GET request to a Subsonic API endpoint.
117    fn get(&self, endpoint: &str) -> Result<SubsonicResponse, SubsonicError> {
118        self.get_with_params(endpoint, &[])
119    }
120
121    fn get_with_params(
122        &self,
123        endpoint: &str,
124        extra: &[(&str, &str)],
125    ) -> Result<SubsonicResponse, SubsonicError> {
126        let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
127        let mut params = self.auth_params()?;
128        for (k, v) in extra {
129            params.insert((*k).to_string(), (*v).to_string());
130        }
131
132        let resp: SubsonicResponseWrapper = self.http.get(&url).query(&params).send()?.json()?;
133
134        let inner = resp.subsonic_response;
135        if inner.status != "ok" {
136            if let Some(err) = inner.error {
137                return Err(SubsonicError::Api {
138                    code: err.code,
139                    message: err.message,
140                });
141            }
142            return Err(SubsonicError::BadResponse);
143        }
144
145        Ok(inner)
146    }
147
148    /// Ping the server — verify connection and credentials.
149    pub fn ping(&self) -> Result<(), SubsonicError> {
150        self.get("ping")?;
151        Ok(())
152    }
153
154    /// Get all artists (indexed).
155    pub fn get_artists(&self) -> Result<Vec<SubsonicArtist>, SubsonicError> {
156        let resp = self.get("getArtists")?;
157        let artists_data = resp.artists.ok_or(SubsonicError::BadResponse)?;
158        let mut all = Vec::new();
159        for index in artists_data.index {
160            all.extend(index.artist);
161        }
162        Ok(all)
163    }
164
165    /// Get an album by ID, including its tracks.
166    pub fn get_album(&self, id: &str) -> Result<SubsonicAlbumFull, SubsonicError> {
167        let resp = self.get_with_params("getAlbum", &[("id", id)])?;
168        resp.album.ok_or(SubsonicError::BadResponse)
169    }
170
171    /// Get a paginated list of albums.
172    pub fn get_album_list(
173        &self,
174        list_type: &str,
175        size: u32,
176        offset: u32,
177    ) -> Result<Vec<SubsonicAlbum>, SubsonicError> {
178        let size_str = size.to_string();
179        let offset_str = offset.to_string();
180        let resp = self.get_with_params(
181            "getAlbumList2",
182            &[
183                ("type", list_type),
184                ("size", &size_str),
185                ("offset", &offset_str),
186            ],
187        )?;
188        Ok(resp.album_list2.map(|al| al.album).unwrap_or_default())
189    }
190
191    /// Build the streaming URL for a track (doesn't make a request).
192    pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
193        self.auth.stream_url(track_id)
194    }
195
196    /// Stream URL without auth params — safe for database storage.
197    pub fn stream_url_template(&self, track_id: &str) -> String {
198        format!("{}/rest/stream?id={}", self.auth.base_url, track_id)
199    }
200
201    /// Download a track to a local path.
202    pub fn download(&self, track_id: &str, dest: &Path) -> Result<(), SubsonicError> {
203        self.download_with_progress(track_id, dest, |_, _| {})
204    }
205
206    /// Download a track with progress reporting.
207    ///
208    /// The callback receives `(bytes_downloaded, total_bytes)`; total is 0 when
209    /// the server sends no Content-Length, and the count restarts from zero if
210    /// an attempt is retried. `dest` only appears once the file is complete.
211    pub fn download_with_progress(
212        &self,
213        track_id: &str,
214        dest: &Path,
215        on_progress: impl Fn(u64, u64),
216    ) -> Result<(), SubsonicError> {
217        self.fetch_to_file("download", track_id, dest, on_progress)
218    }
219
220    /// Fetch a track through `/rest/stream` instead of `/rest/download`.
221    ///
222    /// `download` returns the untranscoded original and is what library sync
223    /// wants from Navidrome. koan's own server implements only `stream`, so
224    /// that is how the remote bridge pulls audio from a `koan serve` instance.
225    pub fn stream_to_file(
226        &self,
227        track_id: &str,
228        dest: &Path,
229        on_progress: impl Fn(u64, u64),
230    ) -> Result<(), SubsonicError> {
231        self.fetch_to_file("stream", track_id, dest, on_progress)
232    }
233
234    fn fetch_to_file(
235        &self,
236        endpoint: &str,
237        track_id: &str,
238        dest: &Path,
239        on_progress: impl Fn(u64, u64),
240    ) -> Result<(), SubsonicError> {
241        let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
242        download::download_with_retries(
243            dest,
244            download::DEFAULT_ATTEMPTS,
245            || {
246                // Fresh auth params per attempt — the salt must not be replayed.
247                let mut params = self
248                    .auth_params()
249                    .map_err(|e| download::DownloadError::Request(e.to_string()))?;
250                params.insert("id".into(), track_id.to_string());
251                Ok(self.downloader.get(&url).query(&params))
252            },
253            on_progress,
254        )?;
255        Ok(())
256    }
257
258    /// Search for tracks/albums/artists.
259    pub fn search(&self, query: &str) -> Result<SubsonicSearchResult, SubsonicError> {
260        let resp = self.get_with_params("search3", &[("query", query)])?;
261        Ok(resp.search_result3.unwrap_or_default())
262    }
263
264    /// Report a play (scrobble).
265    pub fn scrobble(&self, track_id: &str) -> Result<(), SubsonicError> {
266        self.get_with_params("scrobble", &[("id", track_id)])?;
267        Ok(())
268    }
269
270    /// Star (favourite) a track on the server.
271    pub fn star(&self, track_id: &str) -> Result<(), SubsonicError> {
272        self.get_with_params("star", &[("id", track_id)])?;
273        Ok(())
274    }
275
276    /// Unstar (unfavourite) a track on the server.
277    pub fn unstar(&self, track_id: &str) -> Result<(), SubsonicError> {
278        self.get_with_params("unstar", &[("id", track_id)])?;
279        Ok(())
280    }
281
282    /// Get all starred (favourite) songs from the server.
283    pub fn get_starred(&self) -> Result<Vec<SubsonicSong>, SubsonicError> {
284        let resp = self.get("getStarred2")?;
285        Ok(resp.starred2.map(|s| s.song).unwrap_or_default())
286    }
287
288    /// Create a sharing link for one or more resources (songs, albums, etc).
289    /// Returns the created share including its ID which forms the public URL.
290    pub fn create_share(
291        &self,
292        ids: &[&str],
293        description: Option<&str>,
294    ) -> Result<SubsonicShare, SubsonicError> {
295        let url = format!("{}/rest/createShare", self.auth.base_url);
296        let mut params = self.auth_params()?;
297        if let Some(desc) = description {
298            params.insert("description".into(), desc.to_string());
299        }
300
301        // Subsonic API takes `id` as a repeated param for multiple resources.
302        let mut query: Vec<(String, String)> = params.into_iter().collect();
303        for id in ids {
304            query.push(("id".into(), (*id).to_string()));
305        }
306
307        let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
308
309        let inner = resp.subsonic_response;
310        if inner.status != "ok" {
311            if let Some(err) = inner.error {
312                return Err(SubsonicError::Api {
313                    code: err.code,
314                    message: err.message,
315                });
316            }
317            return Err(SubsonicError::BadResponse);
318        }
319
320        inner
321            .shares
322            .and_then(|s| s.share.into_iter().next())
323            .ok_or(SubsonicError::BadResponse)
324    }
325
326    /// Get similar songs for a track (Subsonic getSimilarSongs2 endpoint).
327    /// Returns up to `count` similar songs based on the server's algorithm.
328    pub fn get_similar_songs(
329        &self,
330        song_id: &str,
331        count: usize,
332    ) -> Result<Vec<SubsonicSong>, SubsonicError> {
333        let count_str = count.to_string();
334        let resp = self.get_with_params(
335            "getSimilarSongs2",
336            &[("id", song_id), ("count", &count_str)],
337        )?;
338        Ok(resp.similar_songs2.and_then(|s| s.song).unwrap_or_default())
339    }
340
341    /// Get top songs for an artist by name.
342    pub fn get_top_songs(
343        &self,
344        artist_name: &str,
345        count: usize,
346    ) -> Result<Vec<SubsonicSong>, SubsonicError> {
347        let count_str = count.to_string();
348        let resp = self.get_with_params(
349            "getTopSongs",
350            &[("artist", artist_name), ("count", &count_str)],
351        )?;
352        Ok(resp.top_songs.and_then(|t| t.song).unwrap_or_default())
353    }
354
355    /// The configured server base URL (for constructing share links etc).
356    pub fn base_url(&self) -> &str {
357        &self.auth.base_url
358    }
359}
360
361// --- Response types ---
362
363#[derive(Debug, Deserialize)]
364struct SubsonicResponseWrapper {
365    #[serde(rename = "subsonic-response")]
366    subsonic_response: SubsonicResponse,
367}
368
369#[derive(Debug, Deserialize)]
370#[serde(rename_all = "camelCase")]
371struct SubsonicResponse {
372    status: String,
373    error: Option<SubsonicApiError>,
374    artists: Option<SubsonicArtists>,
375    album: Option<SubsonicAlbumFull>,
376    album_list2: Option<SubsonicAlbumList>,
377    search_result3: Option<SubsonicSearchResult>,
378    starred2: Option<SubsonicStarred>,
379    shares: Option<SubsonicShares>,
380    similar_songs2: Option<SubsonicSimilarSongs>,
381    top_songs: Option<SubsonicTopSongs>,
382}
383
384#[derive(Debug, Deserialize)]
385struct SubsonicApiError {
386    code: i32,
387    message: String,
388}
389
390#[derive(Debug, Deserialize)]
391struct SubsonicArtists {
392    index: Vec<SubsonicArtistIndex>,
393}
394
395#[derive(Debug, Deserialize)]
396struct SubsonicArtistIndex {
397    artist: Vec<SubsonicArtist>,
398}
399
400#[derive(Debug, Clone, Deserialize)]
401#[serde(rename_all = "camelCase")]
402pub struct SubsonicArtist {
403    pub id: String,
404    pub name: String,
405    pub album_count: Option<i32>,
406}
407
408#[derive(Debug, Clone, Deserialize)]
409#[serde(rename_all = "camelCase")]
410pub struct SubsonicAlbum {
411    pub id: String,
412    pub name: String,
413    pub artist: Option<String>,
414    pub artist_id: Option<String>,
415    pub song_count: Option<i32>,
416    pub year: Option<i32>,
417    pub genre: Option<String>,
418    pub created: Option<String>,
419}
420
421#[derive(Debug, Clone, Deserialize)]
422#[serde(rename_all = "camelCase")]
423pub struct SubsonicAlbumFull {
424    pub id: String,
425    pub name: String,
426    pub artist: Option<String>,
427    pub artist_id: Option<String>,
428    pub year: Option<i32>,
429    pub genre: Option<String>,
430    pub song_count: Option<i32>,
431    pub created: Option<String>,
432    #[serde(default)]
433    pub song: Vec<SubsonicSong>,
434}
435
436#[derive(Debug, Clone, Deserialize)]
437#[serde(rename_all = "camelCase")]
438pub struct SubsonicSong {
439    pub id: String,
440    pub title: String,
441    pub album: Option<String>,
442    pub artist: Option<String>,
443    pub track: Option<i32>,
444    pub disc_number: Option<i32>,
445    pub year: Option<i32>,
446    pub genre: Option<String>,
447    pub duration: Option<i64>,
448    pub bit_rate: Option<i32>,
449    pub suffix: Option<String>,
450    pub content_type: Option<String>,
451    pub album_id: Option<String>,
452    pub artist_id: Option<String>,
453}
454
455#[derive(Debug, Deserialize)]
456struct SubsonicAlbumList {
457    #[serde(default)]
458    album: Vec<SubsonicAlbum>,
459}
460
461#[derive(Debug, Default, Deserialize)]
462pub struct SubsonicSearchResult {
463    #[serde(default)]
464    pub artist: Vec<SubsonicArtist>,
465    #[serde(default)]
466    pub album: Vec<SubsonicAlbum>,
467    #[serde(default)]
468    pub song: Vec<SubsonicSong>,
469}
470
471#[derive(Debug, Default, Deserialize)]
472pub struct SubsonicStarred {
473    #[serde(default)]
474    pub song: Vec<SubsonicSong>,
475}
476
477#[derive(Debug, Deserialize)]
478pub struct SubsonicSimilarSongs {
479    pub song: Option<Vec<SubsonicSong>>,
480}
481
482#[derive(Debug, Deserialize)]
483pub struct SubsonicTopSongs {
484    pub song: Option<Vec<SubsonicSong>>,
485}
486
487#[derive(Debug, Deserialize)]
488struct SubsonicShares {
489    #[serde(default)]
490    share: Vec<SubsonicShare>,
491}
492
493#[derive(Debug, Clone, Deserialize)]
494#[serde(rename_all = "camelCase")]
495pub struct SubsonicShare {
496    pub id: String,
497    pub url: Option<String>,
498    pub description: Option<String>,
499    pub username: Option<String>,
500    pub created: Option<String>,
501    pub expires: Option<String>,
502    pub visit_count: Option<i64>,
503}
504
505/// Generate a random hex salt string for Subsonic auth.
506///
507/// The salt goes on the wire next to `md5(password + salt)`, so it has to be
508/// unpredictable — a clock- or counter-derived fallback would make the token
509/// precomputable from a captured exchange. A request without OS entropy fails
510/// rather than authenticating weakly.
511fn random_salt() -> Result<String, getrandom::Error> {
512    let mut buf = [0u8; 12];
513    getrandom::fill(&mut buf)?;
514    Ok(buf.iter().map(|b| format!("{:02x}", b)).collect())
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    // --- SubsonicSong deserialization ---
522
523    #[test]
524    fn test_deserialize_subsonic_song() {
525        let json = r#"{
526            "id": "42",
527            "title": "Space Oddity",
528            "album": "Space Oddity",
529            "artist": "David Bowie",
530            "track": 1,
531            "discNumber": 1,
532            "year": 1969,
533            "genre": "Rock",
534            "duration": 314,
535            "bitRate": 320,
536            "suffix": "mp3",
537            "contentType": "audio/mpeg",
538            "albumId": "7",
539            "artistId": "3"
540        }"#;
541
542        let song: SubsonicSong = serde_json::from_str(json).unwrap();
543
544        assert_eq!(song.id, "42");
545        assert_eq!(song.title, "Space Oddity");
546        assert_eq!(song.album.as_deref(), Some("Space Oddity"));
547        assert_eq!(song.artist.as_deref(), Some("David Bowie"));
548        assert_eq!(song.track, Some(1));
549        assert_eq!(song.disc_number, Some(1));
550        assert_eq!(song.year, Some(1969));
551        assert_eq!(song.genre.as_deref(), Some("Rock"));
552        assert_eq!(song.duration, Some(314));
553        assert_eq!(song.bit_rate, Some(320));
554        assert_eq!(song.suffix.as_deref(), Some("mp3"));
555        assert_eq!(song.content_type.as_deref(), Some("audio/mpeg"));
556        assert_eq!(song.album_id.as_deref(), Some("7"));
557        assert_eq!(song.artist_id.as_deref(), Some("3"));
558    }
559
560    #[test]
561    fn test_deserialize_subsonic_song_optional_fields_absent() {
562        // Only the required fields (id, title) — all Option fields should be None.
563        let json = r#"{"id": "99", "title": "Minimal Track"}"#;
564
565        let song: SubsonicSong = serde_json::from_str(json).unwrap();
566
567        assert_eq!(song.id, "99");
568        assert_eq!(song.title, "Minimal Track");
569        assert!(song.album.is_none());
570        assert!(song.artist.is_none());
571        assert!(song.track.is_none());
572        assert!(song.disc_number.is_none());
573        assert!(song.year.is_none());
574        assert!(song.duration.is_none());
575        assert!(song.bit_rate.is_none());
576    }
577
578    // --- SubsonicAlbum deserialization ---
579
580    #[test]
581    fn test_deserialize_album_list() {
582        let json = r#"{
583            "subsonic-response": {
584                "status": "ok",
585                "version": "1.16.1",
586                "albumList2": {
587                    "album": [
588                        {
589                            "id": "1",
590                            "name": "Abbey Road",
591                            "artist": "The Beatles",
592                            "artistId": "10",
593                            "songCount": 17,
594                            "year": 1969,
595                            "genre": "Rock",
596                            "created": "2020-01-01T00:00:00"
597                        },
598                        {
599                            "id": "2",
600                            "name": "Led Zeppelin IV",
601                            "artist": "Led Zeppelin",
602                            "artistId": "11",
603                            "songCount": 8,
604                            "year": 1971,
605                            "genre": "Hard Rock",
606                            "created": "2020-01-02T00:00:00"
607                        }
608                    ]
609                }
610            }
611        }"#;
612
613        let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
614        let album_list = wrapper
615            .subsonic_response
616            .album_list2
617            .expect("album_list2 should be present");
618
619        assert_eq!(album_list.album.len(), 2);
620
621        let first = &album_list.album[0];
622        assert_eq!(first.id, "1");
623        assert_eq!(first.name, "Abbey Road");
624        assert_eq!(first.artist.as_deref(), Some("The Beatles"));
625        assert_eq!(first.artist_id.as_deref(), Some("10"));
626        assert_eq!(first.song_count, Some(17));
627        assert_eq!(first.year, Some(1969));
628
629        let second = &album_list.album[1];
630        assert_eq!(second.id, "2");
631        assert_eq!(second.name, "Led Zeppelin IV");
632        assert_eq!(second.song_count, Some(8));
633    }
634
635    // --- SubsonicClient auth params ---
636
637    #[test]
638    fn test_auth_params_format() {
639        let client = SubsonicClient::new("http://localhost:4533", "alice", "secret");
640        let params = client.auth_params().unwrap();
641
642        // Must contain exactly these six keys.
643        assert!(params.contains_key("u"), "missing 'u' param");
644        assert!(params.contains_key("t"), "missing 't' param");
645        assert!(params.contains_key("s"), "missing 's' param");
646        assert!(params.contains_key("v"), "missing 'v' param");
647        assert!(params.contains_key("c"), "missing 'c' param");
648        assert!(params.contains_key("f"), "missing 'f' param");
649        assert_eq!(params.len(), 6);
650
651        assert_eq!(params["u"], "alice");
652        assert_eq!(params["v"], "1.16.1");
653        assert_eq!(params["c"], "koan");
654        assert_eq!(params["f"], "json");
655    }
656
657    #[test]
658    fn test_auth_params_token_is_md5_of_password_plus_salt() {
659        let client = SubsonicClient::new("http://localhost:4533", "bob", "letmein");
660        let params = client.auth_params().unwrap();
661
662        let salt = &params["s"];
663        let token = &params["t"];
664
665        // The token must equal md5(password + salt).
666        let expected = format!("{:x}", md5::compute(format!("letmein{}", salt)));
667        assert_eq!(token, &expected);
668    }
669
670    #[test]
671    fn test_auth_params_salt_is_different_each_call() {
672        let client = SubsonicClient::new("http://localhost:4533", "user", "pass");
673        let params1 = client.auth_params().unwrap();
674        let params2 = client.auth_params().unwrap();
675
676        // Salts should differ across calls (random); tokens will differ too.
677        // There is a negligible probability they collide — acceptable in tests.
678        assert_ne!(params1["s"], params2["s"], "salt should be random per call");
679    }
680
681    // --- stream_url ---
682
683    #[test]
684    fn test_stream_url_has_auth() {
685        let client = SubsonicClient::new("http://myserver:4533", "user", "pass");
686        let url = client.stream_url("track-123").unwrap();
687
688        assert!(url.contains("track-123"), "url must include the track id");
689        assert!(url.contains("u=user"), "url must include username param");
690        assert!(url.contains("v=1.16.1"), "url must include api version");
691        assert!(url.contains("c=koan"), "url must include client name");
692        assert!(url.contains("f=json"), "url must include format param");
693        assert!(url.contains("/rest/stream"), "url must target /rest/stream");
694        assert!(
695            url.starts_with("http://myserver:4533"),
696            "url must use the configured base_url"
697        );
698    }
699
700    #[test]
701    fn test_stream_url_base_url_trailing_slash_normalised() {
702        // SubsonicClient::new strips trailing slashes from base_url.
703        let client_with_slash = SubsonicClient::new("http://myserver:4533/", "u", "p");
704        let client_no_slash = SubsonicClient::new("http://myserver:4533", "u", "p");
705
706        let url_with = client_with_slash.stream_url("1").unwrap();
707        let url_without = client_no_slash.stream_url("1").unwrap();
708
709        // Both should produce the same path prefix (no double slash).
710        assert!(
711            url_with.contains("/rest/stream"),
712            "should not have double slash"
713        );
714        assert!(!url_with.contains("//rest"), "should not have double slash");
715        // Both base URLs normalise to the same path structure.
716        assert_eq!(
717            url_with.split('?').next(),
718            url_without.split('?').next(),
719            "path segment should be identical regardless of trailing slash"
720        );
721    }
722
723    // --- SubsonicAlbumFull deserialization ---
724
725    #[test]
726    fn test_deserialize_album_full_with_songs() {
727        let json = r#"{
728            "id": "5",
729            "name": "Kind of Blue",
730            "artist": "Miles Davis",
731            "artistId": "20",
732            "year": 1959,
733            "genre": "Jazz",
734            "songCount": 5,
735            "created": "2021-06-01T00:00:00",
736            "song": [
737                {"id": "101", "title": "So What"},
738                {"id": "102", "title": "Freddie Freeloader"},
739                {"id": "103", "title": "Blue in Green"}
740            ]
741        }"#;
742
743        let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
744
745        assert_eq!(album.id, "5");
746        assert_eq!(album.name, "Kind of Blue");
747        assert_eq!(album.artist.as_deref(), Some("Miles Davis"));
748        assert_eq!(album.year, Some(1959));
749        assert_eq!(album.song.len(), 3);
750        assert_eq!(album.song[0].title, "So What");
751        assert_eq!(album.song[2].id, "103");
752    }
753
754    #[test]
755    fn test_deserialize_album_full_empty_song_list() {
756        // When `song` key is absent, the #[serde(default)] should yield an empty Vec.
757        let json = r#"{"id": "9", "name": "No Tracks Yet"}"#;
758
759        let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
760
761        assert_eq!(album.id, "9");
762        assert!(album.song.is_empty(), "song list should default to empty");
763    }
764
765    // --- SubsonicSearchResult deserialization ---
766
767    #[test]
768    fn test_deserialize_search_result_mixed() {
769        let json = r#"{
770            "artist": [{"id": "1", "name": "Artist One"}],
771            "album":  [{"id": "2", "name": "Album One"}],
772            "song":   [{"id": "3", "title": "Song One"}]
773        }"#;
774
775        let result: SubsonicSearchResult = serde_json::from_str(json).unwrap();
776
777        assert_eq!(result.artist.len(), 1);
778        assert_eq!(result.artist[0].name, "Artist One");
779        assert_eq!(result.album.len(), 1);
780        assert_eq!(result.album[0].name, "Album One");
781        assert_eq!(result.song.len(), 1);
782        assert_eq!(result.song[0].title, "Song One");
783    }
784
785    #[test]
786    fn test_deserialize_search_result_defaults_to_empty() {
787        // All three lists are #[serde(default)], so an empty object is valid.
788        let result: SubsonicSearchResult = serde_json::from_str("{}").unwrap();
789
790        assert!(result.artist.is_empty());
791        assert!(result.album.is_empty());
792        assert!(result.song.is_empty());
793    }
794}