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#[derive(Debug, Clone, PartialEq, Eq)]
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 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 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
81pub 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 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(¶ms).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 fn reject_error_body(resp: &reqwest::blocking::Response) -> Result<(), SubsonicError> {
155 let is_document = resp
156 .headers()
157 .get(reqwest::header::CONTENT_TYPE)
158 .and_then(|v| v.to_str().ok())
159 .is_some_and(|ct| ct.contains("json") || ct.contains("xml"));
160 if is_document {
161 return Err(SubsonicError::BadResponse);
162 }
163 if !resp.status().is_success() {
164 return Err(SubsonicError::BadResponse);
165 }
166 Ok(())
167 }
168
169 pub fn get_cover_art(&self, id: &str, size: Option<u32>) -> Result<Vec<u8>, SubsonicError> {
175 let url = format!("{}/rest/getCoverArt", self.base_url());
176 let mut params = self.auth_params()?;
177 params.insert("id".into(), id.to_string());
178 if let Some(px) = size {
179 params.insert("size".into(), px.to_string());
180 }
181
182 let resp = self.http.get(&url).query(¶ms).send()?;
183 Self::reject_error_body(&resp)?;
184 Ok(resp.bytes()?.to_vec())
185 }
186
187 pub fn ping(&self) -> Result<(), SubsonicError> {
189 self.get("ping")?;
190 Ok(())
191 }
192
193 pub fn get_artists(&self) -> Result<Vec<SubsonicArtist>, SubsonicError> {
195 let resp = self.get("getArtists")?;
196 let artists_data = resp.artists.ok_or(SubsonicError::BadResponse)?;
197 let mut all = Vec::new();
198 for index in artists_data.index {
199 all.extend(index.artist);
200 }
201 Ok(all)
202 }
203
204 pub fn get_album(&self, id: &str) -> Result<SubsonicAlbumFull, SubsonicError> {
206 let resp = self.get_with_params("getAlbum", &[("id", id)])?;
207 resp.album.ok_or(SubsonicError::BadResponse)
208 }
209
210 pub fn get_album_list(
212 &self,
213 list_type: &str,
214 size: u32,
215 offset: u32,
216 ) -> Result<Vec<SubsonicAlbum>, SubsonicError> {
217 let size_str = size.to_string();
218 let offset_str = offset.to_string();
219 let resp = self.get_with_params(
220 "getAlbumList2",
221 &[
222 ("type", list_type),
223 ("size", &size_str),
224 ("offset", &offset_str),
225 ],
226 )?;
227 Ok(resp.album_list2.map(|al| al.album).unwrap_or_default())
228 }
229
230 pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
232 self.auth.stream_url(track_id)
233 }
234
235 pub fn stream_url_template(&self, track_id: &str) -> String {
237 format!("{}/rest/stream?id={}", self.auth.base_url, track_id)
238 }
239
240 pub fn download(&self, track_id: &str, dest: &Path) -> Result<(), SubsonicError> {
242 self.download_with_progress(track_id, dest, |_, _| {})
243 }
244
245 pub fn download_with_progress(
251 &self,
252 track_id: &str,
253 dest: &Path,
254 on_progress: impl Fn(u64, u64),
255 ) -> Result<(), SubsonicError> {
256 self.fetch_to_file("download", track_id, dest, on_progress)
257 }
258
259 pub fn stream_to_file(
265 &self,
266 track_id: &str,
267 dest: &Path,
268 on_progress: impl Fn(u64, u64),
269 ) -> Result<(), SubsonicError> {
270 self.fetch_to_file("stream", track_id, dest, on_progress)
271 }
272
273 fn fetch_to_file(
274 &self,
275 endpoint: &str,
276 track_id: &str,
277 dest: &Path,
278 on_progress: impl Fn(u64, u64),
279 ) -> Result<(), SubsonicError> {
280 let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
281 download::download_with_retries(
282 dest,
283 download::DEFAULT_ATTEMPTS,
284 || {
285 let mut params = self
287 .auth_params()
288 .map_err(|e| download::DownloadError::Request(e.to_string()))?;
289 params.insert("id".into(), track_id.to_string());
290 Ok(self.downloader.get(&url).query(¶ms))
291 },
292 on_progress,
293 )?;
294 Ok(())
295 }
296
297 pub fn search(&self, query: &str) -> Result<SubsonicSearchResult, SubsonicError> {
299 let resp = self.get_with_params("search3", &[("query", query)])?;
300 Ok(resp.search_result3.unwrap_or_default())
301 }
302
303 pub fn scrobble(&self, track_id: &str) -> Result<(), SubsonicError> {
305 self.get_with_params("scrobble", &[("id", track_id)])?;
306 Ok(())
307 }
308
309 pub fn star(&self, track_id: &str) -> Result<(), SubsonicError> {
311 self.get_with_params("star", &[("id", track_id)])?;
312 Ok(())
313 }
314
315 pub fn unstar(&self, track_id: &str) -> Result<(), SubsonicError> {
317 self.get_with_params("unstar", &[("id", track_id)])?;
318 Ok(())
319 }
320
321 pub fn get_starred(&self) -> Result<Vec<SubsonicSong>, SubsonicError> {
323 let resp = self.get("getStarred2")?;
324 Ok(resp.starred2.map(|s| s.song).unwrap_or_default())
325 }
326
327 pub fn get_starred_all(&self) -> Result<SubsonicStarred, SubsonicError> {
332 let resp = self.get("getStarred2")?;
333 Ok(resp.starred2.unwrap_or_default())
334 }
335
336 pub fn star_album(&self, album_id: &str) -> Result<(), SubsonicError> {
339 self.get_with_params("star", &[("albumId", album_id)])?;
340 Ok(())
341 }
342
343 pub fn unstar_album(&self, album_id: &str) -> Result<(), SubsonicError> {
344 self.get_with_params("unstar", &[("albumId", album_id)])?;
345 Ok(())
346 }
347
348 pub fn star_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
349 self.get_with_params("star", &[("artistId", artist_id)])?;
350 Ok(())
351 }
352
353 pub fn unstar_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
354 self.get_with_params("unstar", &[("artistId", artist_id)])?;
355 Ok(())
356 }
357
358 pub fn create_share(
361 &self,
362 ids: &[&str],
363 description: Option<&str>,
364 ) -> Result<SubsonicShare, SubsonicError> {
365 let url = format!("{}/rest/createShare", self.auth.base_url);
366 let mut params = self.auth_params()?;
367 if let Some(desc) = description {
368 params.insert("description".into(), desc.to_string());
369 }
370
371 let mut query: Vec<(String, String)> = params.into_iter().collect();
373 for id in ids {
374 query.push(("id".into(), (*id).to_string()));
375 }
376
377 let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
378
379 let inner = resp.subsonic_response;
380 if inner.status != "ok" {
381 if let Some(err) = inner.error {
382 return Err(SubsonicError::Api {
383 code: err.code,
384 message: err.message,
385 });
386 }
387 return Err(SubsonicError::BadResponse);
388 }
389
390 inner
391 .shares
392 .and_then(|s| s.share.into_iter().next())
393 .ok_or(SubsonicError::BadResponse)
394 }
395
396 pub fn get_similar_songs(
399 &self,
400 song_id: &str,
401 count: usize,
402 ) -> Result<Vec<SubsonicSong>, SubsonicError> {
403 let count_str = count.to_string();
404 let resp = self.get_with_params(
405 "getSimilarSongs2",
406 &[("id", song_id), ("count", &count_str)],
407 )?;
408 Ok(resp.similar_songs2.and_then(|s| s.song).unwrap_or_default())
409 }
410
411 pub fn get_top_songs(
413 &self,
414 artist_name: &str,
415 count: usize,
416 ) -> Result<Vec<SubsonicSong>, SubsonicError> {
417 let count_str = count.to_string();
418 let resp = self.get_with_params(
419 "getTopSongs",
420 &[("artist", artist_name), ("count", &count_str)],
421 )?;
422 Ok(resp.top_songs.and_then(|t| t.song).unwrap_or_default())
423 }
424
425 pub fn get_playlists(&self) -> Result<Vec<SubsonicPlaylist>, SubsonicError> {
429 let resp = self.get("getPlaylists")?;
430 Ok(resp.playlists.map(|p| p.playlist).unwrap_or_default())
431 }
432
433 pub fn get_playlist(&self, id: &str) -> Result<SubsonicPlaylistFull, SubsonicError> {
435 let resp = self.get_with_params("getPlaylist", &[("id", id)])?;
436 resp.playlist.ok_or(SubsonicError::BadResponse)
437 }
438
439 pub fn create_playlist(
448 &self,
449 playlist_id: Option<&str>,
450 name: &str,
451 song_ids: &[String],
452 ) -> Result<Option<SubsonicPlaylistFull>, SubsonicError> {
453 let url = format!("{}/rest/createPlaylist", self.auth.base_url);
454 let mut params = self.auth_params()?;
455 match playlist_id {
456 Some(id) => {
457 params.insert("playlistId".into(), id.to_string());
458 params.insert("name".into(), name.to_string());
461 }
462 None => {
463 params.insert("name".into(), name.to_string());
464 }
465 }
466
467 let mut query: Vec<(String, String)> = params.into_iter().collect();
469 for id in song_ids {
470 query.push(("songId".into(), id.clone()));
471 }
472
473 let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
474 let inner = resp.subsonic_response;
475 if inner.status != "ok" {
476 if let Some(err) = inner.error {
477 return Err(SubsonicError::Api {
478 code: err.code,
479 message: err.message,
480 });
481 }
482 return Err(SubsonicError::BadResponse);
483 }
484 Ok(inner.playlist)
488 }
489
490 pub fn update_playlist(
492 &self,
493 id: &str,
494 name: Option<&str>,
495 comment: Option<&str>,
496 public: Option<bool>,
497 ) -> Result<(), SubsonicError> {
498 let mut extra: Vec<(&str, String)> = vec![("playlistId", id.to_string())];
499 if let Some(name) = name {
500 extra.push(("name", name.to_string()));
501 }
502 if let Some(comment) = comment {
503 extra.push(("comment", comment.to_string()));
504 }
505 if let Some(public) = public {
506 extra.push(("public", public.to_string()));
507 }
508 let borrowed: Vec<(&str, &str)> = extra.iter().map(|(k, v)| (*k, v.as_str())).collect();
509 self.get_with_params("updatePlaylist", &borrowed)?;
510 Ok(())
511 }
512
513 pub fn delete_playlist(&self, id: &str) -> Result<(), SubsonicError> {
514 self.get_with_params("deletePlaylist", &[("id", id)])?;
515 Ok(())
516 }
517
518 pub fn base_url(&self) -> &str {
520 &self.auth.base_url
521 }
522}
523
524#[derive(Debug, Deserialize)]
527struct SubsonicResponseWrapper {
528 #[serde(rename = "subsonic-response")]
529 subsonic_response: SubsonicResponse,
530}
531
532#[derive(Debug, Deserialize)]
533#[serde(rename_all = "camelCase")]
534struct SubsonicResponse {
535 status: String,
536 error: Option<SubsonicApiError>,
537 artists: Option<SubsonicArtists>,
538 album: Option<SubsonicAlbumFull>,
539 album_list2: Option<SubsonicAlbumList>,
540 search_result3: Option<SubsonicSearchResult>,
541 starred2: Option<SubsonicStarred>,
542 shares: Option<SubsonicShares>,
543 similar_songs2: Option<SubsonicSimilarSongs>,
544 top_songs: Option<SubsonicTopSongs>,
545 playlists: Option<SubsonicPlaylists>,
546 playlist: Option<SubsonicPlaylistFull>,
547}
548
549#[derive(Debug, Deserialize)]
550struct SubsonicApiError {
551 code: i32,
552 message: String,
553}
554
555#[derive(Debug, Deserialize)]
556struct SubsonicArtists {
557 index: Vec<SubsonicArtistIndex>,
558}
559
560#[derive(Debug, Deserialize)]
561struct SubsonicArtistIndex {
562 artist: Vec<SubsonicArtist>,
563}
564
565#[derive(Debug, Clone, Deserialize)]
566#[serde(rename_all = "camelCase")]
567pub struct SubsonicArtist {
568 pub id: String,
569 pub name: String,
570 pub album_count: Option<i32>,
571 pub music_brainz_id: Option<String>,
574 pub sort_name: Option<String>,
575}
576
577#[derive(Debug, Clone, Deserialize)]
578#[serde(rename_all = "camelCase")]
579pub struct SubsonicAlbum {
580 pub id: String,
581 pub name: String,
582 pub artist: Option<String>,
583 pub artist_id: Option<String>,
584 pub song_count: Option<i32>,
585 pub year: Option<i32>,
586 pub genre: Option<String>,
587 pub created: Option<String>,
588 pub music_brainz_id: Option<String>,
591 pub sort_name: Option<String>,
592 #[serde(default)]
593 pub record_labels: Vec<SubsonicName>,
594}
595
596#[derive(Debug, Clone, Deserialize)]
599pub struct SubsonicName {
600 pub name: String,
601}
602
603#[derive(Debug, Clone, Deserialize)]
604#[serde(rename_all = "camelCase")]
605pub struct SubsonicAlbumFull {
606 pub id: String,
607 pub name: String,
608 pub artist: Option<String>,
609 pub artist_id: Option<String>,
610 pub year: Option<i32>,
611 pub genre: Option<String>,
612 pub song_count: Option<i32>,
613 pub created: Option<String>,
614 pub music_brainz_id: Option<String>,
615 pub sort_name: Option<String>,
616 #[serde(default)]
617 pub record_labels: Vec<SubsonicName>,
618 #[serde(default)]
619 pub song: Vec<SubsonicSong>,
620}
621
622#[derive(Debug, Clone, Deserialize)]
623#[serde(rename_all = "camelCase")]
624pub struct SubsonicSong {
625 pub id: String,
626 pub title: String,
627 pub album: Option<String>,
628 pub artist: Option<String>,
629 pub track: Option<i32>,
630 pub disc_number: Option<i32>,
631 pub year: Option<i32>,
632 pub genre: Option<String>,
633 pub duration: Option<i64>,
634 pub bit_rate: Option<i32>,
635 pub suffix: Option<String>,
636 pub content_type: Option<String>,
637 pub album_id: Option<String>,
638 pub artist_id: Option<String>,
639 pub sampling_rate: Option<i32>,
642 pub bit_depth: Option<i32>,
643 pub channel_count: Option<i32>,
644 pub music_brainz_id: Option<String>,
645}
646
647#[derive(Debug, Deserialize)]
648struct SubsonicAlbumList {
649 #[serde(default)]
650 album: Vec<SubsonicAlbum>,
651}
652
653#[derive(Debug, Default, Deserialize)]
654pub struct SubsonicSearchResult {
655 #[serde(default)]
656 pub artist: Vec<SubsonicArtist>,
657 #[serde(default)]
658 pub album: Vec<SubsonicAlbum>,
659 #[serde(default)]
660 pub song: Vec<SubsonicSong>,
661}
662
663#[derive(Debug, Default, Deserialize)]
664pub struct SubsonicStarred {
665 #[serde(default)]
666 pub song: Vec<SubsonicSong>,
667 #[serde(default)]
668 pub album: Vec<SubsonicAlbum>,
669 #[serde(default)]
670 pub artist: Vec<SubsonicArtist>,
671}
672
673#[derive(Debug, Deserialize)]
674pub struct SubsonicSimilarSongs {
675 pub song: Option<Vec<SubsonicSong>>,
676}
677
678#[derive(Debug, Deserialize)]
679pub struct SubsonicTopSongs {
680 pub song: Option<Vec<SubsonicSong>>,
681}
682
683#[derive(Debug, Default, Deserialize)]
684struct SubsonicPlaylists {
685 #[serde(default)]
686 playlist: Vec<SubsonicPlaylist>,
687}
688
689#[derive(Debug, Clone, Deserialize)]
691#[serde(rename_all = "camelCase")]
692pub struct SubsonicPlaylist {
693 pub id: String,
694 pub name: String,
695 pub comment: Option<String>,
696 pub owner: Option<String>,
697 #[serde(default)]
698 pub public: bool,
699 pub song_count: Option<i64>,
700 pub duration: Option<i64>,
701 pub created: Option<String>,
702 pub changed: Option<String>,
703}
704
705#[derive(Debug, Clone, Deserialize)]
706#[serde(rename_all = "camelCase")]
707pub struct SubsonicPlaylistFull {
708 #[serde(flatten)]
709 pub playlist: SubsonicPlaylist,
710 #[serde(default)]
711 pub entry: Vec<SubsonicSong>,
712}
713
714#[derive(Debug, Deserialize)]
715struct SubsonicShares {
716 #[serde(default)]
717 share: Vec<SubsonicShare>,
718}
719
720#[derive(Debug, Clone, Deserialize)]
721#[serde(rename_all = "camelCase")]
722pub struct SubsonicShare {
723 pub id: String,
724 pub url: Option<String>,
725 pub description: Option<String>,
726 pub username: Option<String>,
727 pub created: Option<String>,
728 pub expires: Option<String>,
729 pub visit_count: Option<i64>,
730}
731
732fn random_salt() -> Result<String, getrandom::Error> {
739 let mut buf = [0u8; 12];
740 getrandom::fill(&mut buf)?;
741 Ok(buf.iter().map(|b| format!("{:02x}", b)).collect())
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747
748 #[test]
751 fn test_deserialize_subsonic_song() {
752 let json = r#"{
753 "id": "42",
754 "title": "Space Oddity",
755 "album": "Space Oddity",
756 "artist": "David Bowie",
757 "track": 1,
758 "discNumber": 1,
759 "year": 1969,
760 "genre": "Rock",
761 "duration": 314,
762 "bitRate": 320,
763 "suffix": "mp3",
764 "contentType": "audio/mpeg",
765 "albumId": "7",
766 "artistId": "3"
767 }"#;
768
769 let song: SubsonicSong = serde_json::from_str(json).unwrap();
770
771 assert_eq!(song.id, "42");
772 assert_eq!(song.title, "Space Oddity");
773 assert_eq!(song.album.as_deref(), Some("Space Oddity"));
774 assert_eq!(song.artist.as_deref(), Some("David Bowie"));
775 assert_eq!(song.track, Some(1));
776 assert_eq!(song.disc_number, Some(1));
777 assert_eq!(song.year, Some(1969));
778 assert_eq!(song.genre.as_deref(), Some("Rock"));
779 assert_eq!(song.duration, Some(314));
780 assert_eq!(song.bit_rate, Some(320));
781 assert_eq!(song.suffix.as_deref(), Some("mp3"));
782 assert_eq!(song.content_type.as_deref(), Some("audio/mpeg"));
783 assert_eq!(song.album_id.as_deref(), Some("7"));
784 assert_eq!(song.artist_id.as_deref(), Some("3"));
785 }
786
787 #[test]
791 fn opensubsonic_quality_fields_are_read() {
792 let json = r#"{
793 "id": "000XtGC7jsWEbOjDsZi4Xw",
794 "title": "Anguish",
795 "suffix": "flac",
796 "bitRate": 913,
797 "samplingRate": 44100,
798 "bitDepth": 16,
799 "channelCount": 2
800 }"#;
801
802 let song: SubsonicSong = serde_json::from_str(json).unwrap();
803
804 assert_eq!(song.sampling_rate, Some(44100));
805 assert_eq!(song.bit_depth, Some(16));
806 assert_eq!(song.channel_count, Some(2));
807 }
808
809 #[test]
812 fn a_plain_subsonic_song_has_no_quality_figures() {
813 let json = r#"{"id": "1", "title": "Track", "bitRate": 320}"#;
814 let song: SubsonicSong = serde_json::from_str(json).unwrap();
815
816 assert_eq!(song.sampling_rate, None);
817 assert_eq!(song.bit_depth, None);
818 assert_eq!(song.channel_count, None);
819 }
820
821 #[test]
822 fn test_deserialize_subsonic_song_optional_fields_absent() {
823 let json = r#"{"id": "99", "title": "Minimal Track"}"#;
825
826 let song: SubsonicSong = serde_json::from_str(json).unwrap();
827
828 assert_eq!(song.id, "99");
829 assert_eq!(song.title, "Minimal Track");
830 assert!(song.album.is_none());
831 assert!(song.artist.is_none());
832 assert!(song.track.is_none());
833 assert!(song.disc_number.is_none());
834 assert!(song.year.is_none());
835 assert!(song.duration.is_none());
836 assert!(song.bit_rate.is_none());
837 }
838
839 #[test]
842 fn test_deserialize_album_list() {
843 let json = r#"{
844 "subsonic-response": {
845 "status": "ok",
846 "version": "1.16.1",
847 "albumList2": {
848 "album": [
849 {
850 "id": "1",
851 "name": "Abbey Road",
852 "artist": "The Beatles",
853 "artistId": "10",
854 "songCount": 17,
855 "year": 1969,
856 "genre": "Rock",
857 "created": "2020-01-01T00:00:00"
858 },
859 {
860 "id": "2",
861 "name": "Led Zeppelin IV",
862 "artist": "Led Zeppelin",
863 "artistId": "11",
864 "songCount": 8,
865 "year": 1971,
866 "genre": "Hard Rock",
867 "created": "2020-01-02T00:00:00"
868 }
869 ]
870 }
871 }
872 }"#;
873
874 let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
875 let album_list = wrapper
876 .subsonic_response
877 .album_list2
878 .expect("album_list2 should be present");
879
880 assert_eq!(album_list.album.len(), 2);
881
882 let first = &album_list.album[0];
883 assert_eq!(first.id, "1");
884 assert_eq!(first.name, "Abbey Road");
885 assert_eq!(first.artist.as_deref(), Some("The Beatles"));
886 assert_eq!(first.artist_id.as_deref(), Some("10"));
887 assert_eq!(first.song_count, Some(17));
888 assert_eq!(first.year, Some(1969));
889
890 let second = &album_list.album[1];
891 assert_eq!(second.id, "2");
892 assert_eq!(second.name, "Led Zeppelin IV");
893 assert_eq!(second.song_count, Some(8));
894 }
895
896 #[test]
899 fn test_auth_params_format() {
900 let client = SubsonicClient::new("http://localhost:4533", "alice", "secret");
901 let params = client.auth_params().unwrap();
902
903 assert!(params.contains_key("u"), "missing 'u' param");
905 assert!(params.contains_key("t"), "missing 't' param");
906 assert!(params.contains_key("s"), "missing 's' param");
907 assert!(params.contains_key("v"), "missing 'v' param");
908 assert!(params.contains_key("c"), "missing 'c' param");
909 assert!(params.contains_key("f"), "missing 'f' param");
910 assert_eq!(params.len(), 6);
911
912 assert_eq!(params["u"], "alice");
913 assert_eq!(params["v"], "1.16.1");
914 assert_eq!(params["c"], "koan");
915 assert_eq!(params["f"], "json");
916 }
917
918 #[test]
919 fn test_auth_params_token_is_md5_of_password_plus_salt() {
920 let client = SubsonicClient::new("http://localhost:4533", "bob", "letmein");
921 let params = client.auth_params().unwrap();
922
923 let salt = ¶ms["s"];
924 let token = ¶ms["t"];
925
926 let expected = format!("{:x}", md5::compute(format!("letmein{}", salt)));
928 assert_eq!(token, &expected);
929 }
930
931 #[test]
932 fn test_auth_params_salt_is_different_each_call() {
933 let client = SubsonicClient::new("http://localhost:4533", "user", "pass");
934 let params1 = client.auth_params().unwrap();
935 let params2 = client.auth_params().unwrap();
936
937 assert_ne!(params1["s"], params2["s"], "salt should be random per call");
940 }
941
942 #[test]
945 fn test_stream_url_has_auth() {
946 let client = SubsonicClient::new("http://myserver:4533", "user", "pass");
947 let url = client.stream_url("track-123").unwrap();
948
949 assert!(url.contains("track-123"), "url must include the track id");
950 assert!(url.contains("u=user"), "url must include username param");
951 assert!(url.contains("v=1.16.1"), "url must include api version");
952 assert!(url.contains("c=koan"), "url must include client name");
953 assert!(url.contains("f=json"), "url must include format param");
954 assert!(url.contains("/rest/stream"), "url must target /rest/stream");
955 assert!(
956 url.starts_with("http://myserver:4533"),
957 "url must use the configured base_url"
958 );
959 }
960
961 #[test]
962 fn test_stream_url_base_url_trailing_slash_normalised() {
963 let client_with_slash = SubsonicClient::new("http://myserver:4533/", "u", "p");
965 let client_no_slash = SubsonicClient::new("http://myserver:4533", "u", "p");
966
967 let url_with = client_with_slash.stream_url("1").unwrap();
968 let url_without = client_no_slash.stream_url("1").unwrap();
969
970 assert!(
972 url_with.contains("/rest/stream"),
973 "should not have double slash"
974 );
975 assert!(!url_with.contains("//rest"), "should not have double slash");
976 assert_eq!(
978 url_with.split('?').next(),
979 url_without.split('?').next(),
980 "path segment should be identical regardless of trailing slash"
981 );
982 }
983
984 #[test]
987 fn test_deserialize_album_full_with_songs() {
988 let json = r#"{
989 "id": "5",
990 "name": "Kind of Blue",
991 "artist": "Miles Davis",
992 "artistId": "20",
993 "year": 1959,
994 "genre": "Jazz",
995 "songCount": 5,
996 "created": "2021-06-01T00:00:00",
997 "song": [
998 {"id": "101", "title": "So What"},
999 {"id": "102", "title": "Freddie Freeloader"},
1000 {"id": "103", "title": "Blue in Green"}
1001 ]
1002 }"#;
1003
1004 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1005
1006 assert_eq!(album.id, "5");
1007 assert_eq!(album.name, "Kind of Blue");
1008 assert_eq!(album.artist.as_deref(), Some("Miles Davis"));
1009 assert_eq!(album.year, Some(1959));
1010 assert_eq!(album.song.len(), 3);
1011 assert_eq!(album.song[0].title, "So What");
1012 assert_eq!(album.song[2].id, "103");
1013 }
1014
1015 #[test]
1016 fn test_deserialize_album_full_empty_song_list() {
1017 let json = r#"{"id": "9", "name": "No Tracks Yet"}"#;
1019
1020 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1021
1022 assert_eq!(album.id, "9");
1023 assert!(album.song.is_empty(), "song list should default to empty");
1024 }
1025
1026 #[test]
1029 fn test_deserialize_search_result_mixed() {
1030 let json = r#"{
1031 "artist": [{"id": "1", "name": "Artist One"}],
1032 "album": [{"id": "2", "name": "Album One"}],
1033 "song": [{"id": "3", "title": "Song One"}]
1034 }"#;
1035
1036 let result: SubsonicSearchResult = serde_json::from_str(json).unwrap();
1037
1038 assert_eq!(result.artist.len(), 1);
1039 assert_eq!(result.artist[0].name, "Artist One");
1040 assert_eq!(result.album.len(), 1);
1041 assert_eq!(result.album[0].name, "Album One");
1042 assert_eq!(result.song.len(), 1);
1043 assert_eq!(result.song[0].title, "Song One");
1044 }
1045
1046 #[test]
1047 fn test_deserialize_search_result_defaults_to_empty() {
1048 let result: SubsonicSearchResult = serde_json::from_str("{}").unwrap();
1050
1051 assert!(result.artist.is_empty());
1052 assert!(result.album.is_empty());
1053 assert!(result.song.is_empty());
1054 }
1055}