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        let mut file = std::fs::File::create(dest)?;
179        let mut downloaded: u64 = 0;
180        let mut buf = [0u8; 64 * 1024]; // 64KB chunks
181        loop {
182            let n = std::io::Read::read(&mut resp, &mut buf).map_err(SubsonicError::Io)?;
183            if n == 0 {
184                break;
185            }
186            file.write_all(&buf[..n])?;
187            downloaded += n as u64;
188            on_progress(downloaded, total);
189        }
190        file.flush()?;
191        Ok(())
192    }
193
194    /// Search for tracks/albums/artists.
195    pub fn search(&self, query: &str) -> Result<SubsonicSearchResult, SubsonicError> {
196        let resp = self.get_with_params("search3", &[("query", query)])?;
197        Ok(resp.search_result3.unwrap_or_default())
198    }
199
200    /// Report a play (scrobble).
201    pub fn scrobble(&self, track_id: &str) -> Result<(), SubsonicError> {
202        self.get_with_params("scrobble", &[("id", track_id)])?;
203        Ok(())
204    }
205
206    /// Star (favourite) a track on the server.
207    pub fn star(&self, track_id: &str) -> Result<(), SubsonicError> {
208        self.get_with_params("star", &[("id", track_id)])?;
209        Ok(())
210    }
211
212    /// Unstar (unfavourite) a track on the server.
213    pub fn unstar(&self, track_id: &str) -> Result<(), SubsonicError> {
214        self.get_with_params("unstar", &[("id", track_id)])?;
215        Ok(())
216    }
217
218    /// Get all starred (favourite) songs from the server.
219    pub fn get_starred(&self) -> Result<Vec<SubsonicSong>, SubsonicError> {
220        let resp = self.get("getStarred2")?;
221        Ok(resp.starred2.map(|s| s.song).unwrap_or_default())
222    }
223
224    /// Create a sharing link for one or more resources (songs, albums, etc).
225    /// Returns the created share including its ID which forms the public URL.
226    pub fn create_share(
227        &self,
228        ids: &[&str],
229        description: Option<&str>,
230    ) -> Result<SubsonicShare, SubsonicError> {
231        let url = format!("{}/rest/createShare", self.base_url);
232        let mut params = self.auth_params();
233        if let Some(desc) = description {
234            params.insert("description".into(), desc.to_string());
235        }
236
237        // Subsonic API takes `id` as a repeated param for multiple resources.
238        let mut query: Vec<(String, String)> = params.into_iter().collect();
239        for id in ids {
240            query.push(("id".into(), (*id).to_string()));
241        }
242
243        let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
244
245        let inner = resp.subsonic_response;
246        if inner.status != "ok" {
247            if let Some(err) = inner.error {
248                return Err(SubsonicError::Api {
249                    code: err.code,
250                    message: err.message,
251                });
252            }
253            return Err(SubsonicError::BadResponse);
254        }
255
256        inner
257            .shares
258            .and_then(|s| s.share.into_iter().next())
259            .ok_or(SubsonicError::BadResponse)
260    }
261
262    /// Get similar songs for a track (Subsonic getSimilarSongs2 endpoint).
263    /// Returns up to `count` similar songs based on the server's algorithm.
264    pub fn get_similar_songs(
265        &self,
266        song_id: &str,
267        count: usize,
268    ) -> Result<Vec<SubsonicSong>, SubsonicError> {
269        let count_str = count.to_string();
270        let resp = self.get_with_params(
271            "getSimilarSongs2",
272            &[("id", song_id), ("count", &count_str)],
273        )?;
274        Ok(resp.similar_songs2.and_then(|s| s.song).unwrap_or_default())
275    }
276
277    /// Get top songs for an artist by name.
278    pub fn get_top_songs(
279        &self,
280        artist_name: &str,
281        count: usize,
282    ) -> Result<Vec<SubsonicSong>, SubsonicError> {
283        let count_str = count.to_string();
284        let resp = self.get_with_params(
285            "getTopSongs",
286            &[("artist", artist_name), ("count", &count_str)],
287        )?;
288        Ok(resp.top_songs.and_then(|t| t.song).unwrap_or_default())
289    }
290
291    /// The configured server base URL (for constructing share links etc).
292    pub fn base_url(&self) -> &str {
293        &self.base_url
294    }
295}
296
297// --- Response types ---
298
299#[derive(Debug, Deserialize)]
300struct SubsonicResponseWrapper {
301    #[serde(rename = "subsonic-response")]
302    subsonic_response: SubsonicResponse,
303}
304
305#[derive(Debug, Deserialize)]
306#[serde(rename_all = "camelCase")]
307struct SubsonicResponse {
308    status: String,
309    error: Option<SubsonicApiError>,
310    artists: Option<SubsonicArtists>,
311    album: Option<SubsonicAlbumFull>,
312    album_list2: Option<SubsonicAlbumList>,
313    search_result3: Option<SubsonicSearchResult>,
314    starred2: Option<SubsonicStarred>,
315    shares: Option<SubsonicShares>,
316    similar_songs2: Option<SubsonicSimilarSongs>,
317    top_songs: Option<SubsonicTopSongs>,
318}
319
320#[derive(Debug, Deserialize)]
321struct SubsonicApiError {
322    code: i32,
323    message: String,
324}
325
326#[derive(Debug, Deserialize)]
327struct SubsonicArtists {
328    index: Vec<SubsonicArtistIndex>,
329}
330
331#[derive(Debug, Deserialize)]
332struct SubsonicArtistIndex {
333    artist: Vec<SubsonicArtist>,
334}
335
336#[derive(Debug, Clone, Deserialize)]
337#[serde(rename_all = "camelCase")]
338pub struct SubsonicArtist {
339    pub id: String,
340    pub name: String,
341    pub album_count: Option<i32>,
342}
343
344#[derive(Debug, Clone, Deserialize)]
345#[serde(rename_all = "camelCase")]
346pub struct SubsonicAlbum {
347    pub id: String,
348    pub name: String,
349    pub artist: Option<String>,
350    pub artist_id: Option<String>,
351    pub song_count: Option<i32>,
352    pub year: Option<i32>,
353    pub genre: Option<String>,
354    pub created: Option<String>,
355}
356
357#[derive(Debug, Clone, Deserialize)]
358#[serde(rename_all = "camelCase")]
359pub struct SubsonicAlbumFull {
360    pub id: String,
361    pub name: String,
362    pub artist: Option<String>,
363    pub artist_id: Option<String>,
364    pub year: Option<i32>,
365    pub genre: Option<String>,
366    pub song_count: Option<i32>,
367    pub created: Option<String>,
368    #[serde(default)]
369    pub song: Vec<SubsonicSong>,
370}
371
372#[derive(Debug, Clone, Deserialize)]
373#[serde(rename_all = "camelCase")]
374pub struct SubsonicSong {
375    pub id: String,
376    pub title: String,
377    pub album: Option<String>,
378    pub artist: Option<String>,
379    pub track: Option<i32>,
380    pub disc_number: Option<i32>,
381    pub year: Option<i32>,
382    pub genre: Option<String>,
383    pub duration: Option<i64>,
384    pub bit_rate: Option<i32>,
385    pub suffix: Option<String>,
386    pub content_type: Option<String>,
387    pub album_id: Option<String>,
388    pub artist_id: Option<String>,
389}
390
391#[derive(Debug, Deserialize)]
392struct SubsonicAlbumList {
393    #[serde(default)]
394    album: Vec<SubsonicAlbum>,
395}
396
397#[derive(Debug, Default, Deserialize)]
398pub struct SubsonicSearchResult {
399    #[serde(default)]
400    pub artist: Vec<SubsonicArtist>,
401    #[serde(default)]
402    pub album: Vec<SubsonicAlbum>,
403    #[serde(default)]
404    pub song: Vec<SubsonicSong>,
405}
406
407#[derive(Debug, Default, Deserialize)]
408pub struct SubsonicStarred {
409    #[serde(default)]
410    pub song: Vec<SubsonicSong>,
411}
412
413#[derive(Debug, Deserialize)]
414pub struct SubsonicSimilarSongs {
415    pub song: Option<Vec<SubsonicSong>>,
416}
417
418#[derive(Debug, Deserialize)]
419pub struct SubsonicTopSongs {
420    pub song: Option<Vec<SubsonicSong>>,
421}
422
423#[derive(Debug, Deserialize)]
424struct SubsonicShares {
425    #[serde(default)]
426    share: Vec<SubsonicShare>,
427}
428
429#[derive(Debug, Clone, Deserialize)]
430#[serde(rename_all = "camelCase")]
431pub struct SubsonicShare {
432    pub id: String,
433    pub url: Option<String>,
434    pub description: Option<String>,
435    pub username: Option<String>,
436    pub created: Option<String>,
437    pub expires: Option<String>,
438    pub visit_count: Option<i64>,
439}
440
441/// Generate a random hex salt string for Subsonic auth.
442fn random_salt() -> String {
443    let mut buf = [0u8; 12];
444    getrandom::getrandom(&mut buf).expect("failed to generate random salt");
445    buf.iter().map(|b| format!("{:02x}", b)).collect()
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    // --- SubsonicSong deserialization ---
453
454    #[test]
455    fn test_deserialize_subsonic_song() {
456        let json = r#"{
457            "id": "42",
458            "title": "Space Oddity",
459            "album": "Space Oddity",
460            "artist": "David Bowie",
461            "track": 1,
462            "discNumber": 1,
463            "year": 1969,
464            "genre": "Rock",
465            "duration": 314,
466            "bitRate": 320,
467            "suffix": "mp3",
468            "contentType": "audio/mpeg",
469            "albumId": "7",
470            "artistId": "3"
471        }"#;
472
473        let song: SubsonicSong = serde_json::from_str(json).unwrap();
474
475        assert_eq!(song.id, "42");
476        assert_eq!(song.title, "Space Oddity");
477        assert_eq!(song.album.as_deref(), Some("Space Oddity"));
478        assert_eq!(song.artist.as_deref(), Some("David Bowie"));
479        assert_eq!(song.track, Some(1));
480        assert_eq!(song.disc_number, Some(1));
481        assert_eq!(song.year, Some(1969));
482        assert_eq!(song.genre.as_deref(), Some("Rock"));
483        assert_eq!(song.duration, Some(314));
484        assert_eq!(song.bit_rate, Some(320));
485        assert_eq!(song.suffix.as_deref(), Some("mp3"));
486        assert_eq!(song.content_type.as_deref(), Some("audio/mpeg"));
487        assert_eq!(song.album_id.as_deref(), Some("7"));
488        assert_eq!(song.artist_id.as_deref(), Some("3"));
489    }
490
491    #[test]
492    fn test_deserialize_subsonic_song_optional_fields_absent() {
493        // Only the required fields (id, title) — all Option fields should be None.
494        let json = r#"{"id": "99", "title": "Minimal Track"}"#;
495
496        let song: SubsonicSong = serde_json::from_str(json).unwrap();
497
498        assert_eq!(song.id, "99");
499        assert_eq!(song.title, "Minimal Track");
500        assert!(song.album.is_none());
501        assert!(song.artist.is_none());
502        assert!(song.track.is_none());
503        assert!(song.disc_number.is_none());
504        assert!(song.year.is_none());
505        assert!(song.duration.is_none());
506        assert!(song.bit_rate.is_none());
507    }
508
509    // --- SubsonicAlbum deserialization ---
510
511    #[test]
512    fn test_deserialize_album_list() {
513        let json = r#"{
514            "subsonic-response": {
515                "status": "ok",
516                "version": "1.16.1",
517                "albumList2": {
518                    "album": [
519                        {
520                            "id": "1",
521                            "name": "Abbey Road",
522                            "artist": "The Beatles",
523                            "artistId": "10",
524                            "songCount": 17,
525                            "year": 1969,
526                            "genre": "Rock",
527                            "created": "2020-01-01T00:00:00"
528                        },
529                        {
530                            "id": "2",
531                            "name": "Led Zeppelin IV",
532                            "artist": "Led Zeppelin",
533                            "artistId": "11",
534                            "songCount": 8,
535                            "year": 1971,
536                            "genre": "Hard Rock",
537                            "created": "2020-01-02T00:00:00"
538                        }
539                    ]
540                }
541            }
542        }"#;
543
544        let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
545        let album_list = wrapper
546            .subsonic_response
547            .album_list2
548            .expect("album_list2 should be present");
549
550        assert_eq!(album_list.album.len(), 2);
551
552        let first = &album_list.album[0];
553        assert_eq!(first.id, "1");
554        assert_eq!(first.name, "Abbey Road");
555        assert_eq!(first.artist.as_deref(), Some("The Beatles"));
556        assert_eq!(first.artist_id.as_deref(), Some("10"));
557        assert_eq!(first.song_count, Some(17));
558        assert_eq!(first.year, Some(1969));
559
560        let second = &album_list.album[1];
561        assert_eq!(second.id, "2");
562        assert_eq!(second.name, "Led Zeppelin IV");
563        assert_eq!(second.song_count, Some(8));
564    }
565
566    // --- SubsonicClient auth params ---
567
568    #[test]
569    fn test_auth_params_format() {
570        let client = SubsonicClient::new("http://localhost:4533", "alice", "secret");
571        let params = client.auth_params();
572
573        // Must contain exactly these six keys.
574        assert!(params.contains_key("u"), "missing 'u' param");
575        assert!(params.contains_key("t"), "missing 't' param");
576        assert!(params.contains_key("s"), "missing 's' param");
577        assert!(params.contains_key("v"), "missing 'v' param");
578        assert!(params.contains_key("c"), "missing 'c' param");
579        assert!(params.contains_key("f"), "missing 'f' param");
580        assert_eq!(params.len(), 6);
581
582        assert_eq!(params["u"], "alice");
583        assert_eq!(params["v"], "1.16.1");
584        assert_eq!(params["c"], "koan");
585        assert_eq!(params["f"], "json");
586    }
587
588    #[test]
589    fn test_auth_params_token_is_md5_of_password_plus_salt() {
590        let client = SubsonicClient::new("http://localhost:4533", "bob", "letmein");
591        let params = client.auth_params();
592
593        let salt = &params["s"];
594        let token = &params["t"];
595
596        // The token must equal md5(password + salt).
597        let expected = format!("{:x}", md5::compute(format!("letmein{}", salt)));
598        assert_eq!(token, &expected);
599    }
600
601    #[test]
602    fn test_auth_params_salt_is_different_each_call() {
603        let client = SubsonicClient::new("http://localhost:4533", "user", "pass");
604        let params1 = client.auth_params();
605        let params2 = client.auth_params();
606
607        // Salts should differ across calls (random); tokens will differ too.
608        // There is a negligible probability they collide — acceptable in tests.
609        assert_ne!(params1["s"], params2["s"], "salt should be random per call");
610    }
611
612    // --- stream_url ---
613
614    #[test]
615    fn test_stream_url_has_auth() {
616        let client = SubsonicClient::new("http://myserver:4533", "user", "pass");
617        let url = client.stream_url("track-123");
618
619        assert!(url.contains("track-123"), "url must include the track id");
620        assert!(url.contains("u=user"), "url must include username param");
621        assert!(url.contains("v=1.16.1"), "url must include api version");
622        assert!(url.contains("c=koan"), "url must include client name");
623        assert!(url.contains("f=json"), "url must include format param");
624        assert!(url.contains("/rest/stream"), "url must target /rest/stream");
625        assert!(
626            url.starts_with("http://myserver:4533"),
627            "url must use the configured base_url"
628        );
629    }
630
631    #[test]
632    fn test_stream_url_base_url_trailing_slash_normalised() {
633        // SubsonicClient::new strips trailing slashes from base_url.
634        let client_with_slash = SubsonicClient::new("http://myserver:4533/", "u", "p");
635        let client_no_slash = SubsonicClient::new("http://myserver:4533", "u", "p");
636
637        let url_with = client_with_slash.stream_url("1");
638        let url_without = client_no_slash.stream_url("1");
639
640        // Both should produce the same path prefix (no double slash).
641        assert!(
642            url_with.contains("/rest/stream"),
643            "should not have double slash"
644        );
645        assert!(!url_with.contains("//rest"), "should not have double slash");
646        // Both base URLs normalise to the same path structure.
647        assert_eq!(
648            url_with.split('?').next(),
649            url_without.split('?').next(),
650            "path segment should be identical regardless of trailing slash"
651        );
652    }
653
654    // --- SubsonicAlbumFull deserialization ---
655
656    #[test]
657    fn test_deserialize_album_full_with_songs() {
658        let json = r#"{
659            "id": "5",
660            "name": "Kind of Blue",
661            "artist": "Miles Davis",
662            "artistId": "20",
663            "year": 1959,
664            "genre": "Jazz",
665            "songCount": 5,
666            "created": "2021-06-01T00:00:00",
667            "song": [
668                {"id": "101", "title": "So What"},
669                {"id": "102", "title": "Freddie Freeloader"},
670                {"id": "103", "title": "Blue in Green"}
671            ]
672        }"#;
673
674        let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
675
676        assert_eq!(album.id, "5");
677        assert_eq!(album.name, "Kind of Blue");
678        assert_eq!(album.artist.as_deref(), Some("Miles Davis"));
679        assert_eq!(album.year, Some(1959));
680        assert_eq!(album.song.len(), 3);
681        assert_eq!(album.song[0].title, "So What");
682        assert_eq!(album.song[2].id, "103");
683    }
684
685    #[test]
686    fn test_deserialize_album_full_empty_song_list() {
687        // When `song` key is absent, the #[serde(default)] should yield an empty Vec.
688        let json = r#"{"id": "9", "name": "No Tracks Yet"}"#;
689
690        let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
691
692        assert_eq!(album.id, "9");
693        assert!(album.song.is_empty(), "song list should default to empty");
694    }
695
696    // --- SubsonicSearchResult deserialization ---
697
698    #[test]
699    fn test_deserialize_search_result_mixed() {
700        let json = r#"{
701            "artist": [{"id": "1", "name": "Artist One"}],
702            "album":  [{"id": "2", "name": "Album One"}],
703            "song":   [{"id": "3", "title": "Song One"}]
704        }"#;
705
706        let result: SubsonicSearchResult = serde_json::from_str(json).unwrap();
707
708        assert_eq!(result.artist.len(), 1);
709        assert_eq!(result.artist[0].name, "Artist One");
710        assert_eq!(result.album.len(), 1);
711        assert_eq!(result.album[0].name, "Album One");
712        assert_eq!(result.song.len(), 1);
713        assert_eq!(result.song[0].title, "Song One");
714    }
715
716    #[test]
717    fn test_deserialize_search_result_defaults_to_empty() {
718        // All three lists are #[serde(default)], so an empty object is valid.
719        let result: SubsonicSearchResult = serde_json::from_str("{}").unwrap();
720
721        assert!(result.artist.is_empty());
722        assert!(result.album.is_empty());
723        assert!(result.song.is_empty());
724    }
725}