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