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> {
57 let mut params = HashMap::new();
58 params.insert("u".into(), self.username.clone());
59 if self.base_url.starts_with("https://") {
60 let hex: String = self.password.bytes().map(|b| format!("{b:02x}")).collect();
61 params.insert("p".into(), format!("enc:{hex}"));
62 } else {
63 let salt = random_salt()?;
64 let token = format!("{:x}", md5::compute(format!("{}{}", self.password, salt)));
65 params.insert("t".into(), token);
66 params.insert("s".into(), salt);
67 }
68 params.insert("v".into(), API_VERSION.into());
69 params.insert("c".into(), CLIENT_NAME.into());
70 params.insert("f".into(), "json".into());
71 Ok(params)
72 }
73
74 pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
76 let query: String = self
77 .params()?
78 .iter()
79 .map(|(k, v)| format!("{}={}", k, v))
80 .collect::<Vec<_>>()
81 .join("&");
82 Ok(format!(
83 "{}/rest/stream?id={}&{}",
84 self.base_url, track_id, query
85 ))
86 }
87}
88
89pub struct SubsonicClient {
96 auth: SubsonicAuth,
97 http: reqwest::blocking::Client,
98 downloader: reqwest::blocking::Client,
99}
100
101impl SubsonicClient {
102 pub fn new(base_url: &str, username: &str, password: &str) -> Self {
103 Self::from_auth(SubsonicAuth::new(base_url, username, password))
104 }
105
106 pub fn from_auth(auth: SubsonicAuth) -> Self {
107 Self {
108 auth,
109 http: download::api_client().unwrap_or_else(|e| {
110 log::warn!("falling back to default HTTP client: {}", e);
111 reqwest::blocking::Client::new()
112 }),
113 downloader: download::download_client().unwrap_or_else(|e| {
114 log::warn!("falling back to default download client: {}", e);
115 reqwest::blocking::Client::new()
116 }),
117 }
118 }
119
120 fn auth_params(&self) -> Result<HashMap<String, String>, SubsonicError> {
121 self.auth.params()
122 }
123
124 fn get(&self, endpoint: &str) -> Result<SubsonicResponse, SubsonicError> {
126 self.get_with_params(endpoint, &[])
127 }
128
129 fn get_with_params(
130 &self,
131 endpoint: &str,
132 extra: &[(&str, &str)],
133 ) -> Result<SubsonicResponse, SubsonicError> {
134 let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
135 let mut params = self.auth_params()?;
136 for (k, v) in extra {
137 params.insert((*k).to_string(), (*v).to_string());
138 }
139
140 let resp: SubsonicResponseWrapper = self.http.get(&url).query(¶ms).send()?.json()?;
141
142 let inner = resp.subsonic_response;
143 if inner.status != "ok" {
144 if let Some(err) = inner.error {
145 return Err(SubsonicError::Api {
146 code: err.code,
147 message: err.message,
148 });
149 }
150 return Err(SubsonicError::BadResponse);
151 }
152
153 Ok(inner)
154 }
155
156 fn reject_error_body(resp: &reqwest::blocking::Response) -> Result<(), SubsonicError> {
163 let is_document = resp
164 .headers()
165 .get(reqwest::header::CONTENT_TYPE)
166 .and_then(|v| v.to_str().ok())
167 .is_some_and(|ct| ct.contains("json") || ct.contains("xml"));
168 if is_document {
169 return Err(SubsonicError::BadResponse);
170 }
171 if !resp.status().is_success() {
172 return Err(SubsonicError::BadResponse);
173 }
174 Ok(())
175 }
176
177 pub fn get_cover_art(&self, id: &str, size: Option<u32>) -> Result<Vec<u8>, SubsonicError> {
183 let url = format!("{}/rest/getCoverArt", self.base_url());
184 let mut params = self.auth_params()?;
185 params.insert("id".into(), id.to_string());
186 if let Some(px) = size {
187 params.insert("size".into(), px.to_string());
188 }
189
190 let resp = self.http.get(&url).query(¶ms).send()?;
191 Self::reject_error_body(&resp)?;
192 Ok(resp.bytes()?.to_vec())
193 }
194
195 pub fn ping(&self) -> Result<(), SubsonicError> {
197 self.get("ping")?;
198 Ok(())
199 }
200
201 pub fn get_artists(&self) -> Result<Vec<SubsonicArtist>, SubsonicError> {
203 let resp = self.get("getArtists")?;
204 let artists_data = resp.artists.ok_or(SubsonicError::BadResponse)?;
205 let mut all = Vec::new();
206 for index in artists_data.index {
207 all.extend(index.artist);
208 }
209 Ok(all)
210 }
211
212 pub fn get_album(&self, id: &str) -> Result<SubsonicAlbumFull, SubsonicError> {
214 let resp = self.get_with_params("getAlbum", &[("id", id)])?;
215 resp.album.ok_or(SubsonicError::BadResponse)
216 }
217
218 pub fn get_album_list(
220 &self,
221 list_type: &str,
222 size: u32,
223 offset: u32,
224 ) -> Result<Vec<SubsonicAlbum>, SubsonicError> {
225 let size_str = size.to_string();
226 let offset_str = offset.to_string();
227 let resp = self.get_with_params(
228 "getAlbumList2",
229 &[
230 ("type", list_type),
231 ("size", &size_str),
232 ("offset", &offset_str),
233 ],
234 )?;
235 Ok(resp.album_list2.map(|al| al.album).unwrap_or_default())
236 }
237
238 pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
240 self.auth.stream_url(track_id)
241 }
242
243 pub fn stream_url_template(&self, track_id: &str) -> String {
245 format!("{}/rest/stream?id={}", self.auth.base_url, track_id)
246 }
247
248 pub fn download(&self, track_id: &str, dest: &Path) -> Result<(), SubsonicError> {
250 self.download_with_progress(track_id, dest, |_, _| {})
251 }
252
253 pub fn download_with_progress(
259 &self,
260 track_id: &str,
261 dest: &Path,
262 on_progress: impl Fn(u64, u64),
263 ) -> Result<(), SubsonicError> {
264 self.fetch_to_file("download", track_id, dest, on_progress)
265 }
266
267 pub fn stream_to_file(
273 &self,
274 track_id: &str,
275 dest: &Path,
276 on_progress: impl Fn(u64, u64),
277 ) -> Result<(), SubsonicError> {
278 self.fetch_to_file("stream", track_id, dest, on_progress)
279 }
280
281 fn fetch_to_file(
282 &self,
283 endpoint: &str,
284 track_id: &str,
285 dest: &Path,
286 on_progress: impl Fn(u64, u64),
287 ) -> Result<(), SubsonicError> {
288 let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
289 download::download_with_retries(
290 dest,
291 download::DEFAULT_ATTEMPTS,
292 || {
293 let mut params = self
295 .auth_params()
296 .map_err(|e| download::DownloadError::Request(e.to_string()))?;
297 params.insert("id".into(), track_id.to_string());
298 Ok(self.downloader.get(&url).query(¶ms))
299 },
300 on_progress,
301 )?;
302 Ok(())
303 }
304
305 pub fn all_songs_page(
311 &self,
312 size: u32,
313 offset: u32,
314 ) -> Result<Vec<SubsonicSong>, SubsonicError> {
315 let size = size.to_string();
316 let offset = offset.to_string();
317 let resp = self.get_with_params(
318 "search3",
319 &[
320 ("query", ""),
321 ("artistCount", "0"),
322 ("albumCount", "0"),
323 ("songCount", &size),
324 ("songOffset", &offset),
325 ],
326 )?;
327 Ok(resp.search_result3.map(|r| r.song).unwrap_or_default())
328 }
329
330 pub fn song_count(&self) -> Result<Option<u64>, SubsonicError> {
333 let resp = self.get("getScanStatus")?;
334 Ok(resp.scan_status.and_then(|s| s.count))
335 }
336
337 pub fn search(&self, query: &str) -> Result<SubsonicSearchResult, SubsonicError> {
339 let resp = self.get_with_params("search3", &[("query", query)])?;
340 Ok(resp.search_result3.unwrap_or_default())
341 }
342
343 pub fn scrobble(&self, track_id: &str) -> Result<(), SubsonicError> {
345 self.get_with_params("scrobble", &[("id", track_id)])?;
346 Ok(())
347 }
348
349 pub fn star(&self, track_id: &str) -> Result<(), SubsonicError> {
351 self.get_with_params("star", &[("id", track_id)])?;
352 Ok(())
353 }
354
355 pub fn unstar(&self, track_id: &str) -> Result<(), SubsonicError> {
357 self.get_with_params("unstar", &[("id", track_id)])?;
358 Ok(())
359 }
360
361 pub fn get_starred(&self) -> Result<Vec<SubsonicSong>, SubsonicError> {
363 let resp = self.get("getStarred2")?;
364 Ok(resp.starred2.map(|s| s.song).unwrap_or_default())
365 }
366
367 pub fn get_starred_all(&self) -> Result<SubsonicStarred, SubsonicError> {
372 let resp = self.get("getStarred2")?;
373 Ok(resp.starred2.unwrap_or_default())
374 }
375
376 pub fn star_album(&self, album_id: &str) -> Result<(), SubsonicError> {
379 self.get_with_params("star", &[("albumId", album_id)])?;
380 Ok(())
381 }
382
383 pub fn unstar_album(&self, album_id: &str) -> Result<(), SubsonicError> {
384 self.get_with_params("unstar", &[("albumId", album_id)])?;
385 Ok(())
386 }
387
388 pub fn star_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
389 self.get_with_params("star", &[("artistId", artist_id)])?;
390 Ok(())
391 }
392
393 pub fn unstar_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
394 self.get_with_params("unstar", &[("artistId", artist_id)])?;
395 Ok(())
396 }
397
398 pub fn create_share(
401 &self,
402 ids: &[&str],
403 description: Option<&str>,
404 ) -> Result<SubsonicShare, SubsonicError> {
405 let url = format!("{}/rest/createShare", self.auth.base_url);
406 let mut params = self.auth_params()?;
407 if let Some(desc) = description {
408 params.insert("description".into(), desc.to_string());
409 }
410
411 let mut query: Vec<(String, String)> = params.into_iter().collect();
413 for id in ids {
414 query.push(("id".into(), (*id).to_string()));
415 }
416
417 let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
418
419 let inner = resp.subsonic_response;
420 if inner.status != "ok" {
421 if let Some(err) = inner.error {
422 return Err(SubsonicError::Api {
423 code: err.code,
424 message: err.message,
425 });
426 }
427 return Err(SubsonicError::BadResponse);
428 }
429
430 inner
431 .shares
432 .and_then(|s| s.share.into_iter().next())
433 .ok_or(SubsonicError::BadResponse)
434 }
435
436 pub fn get_similar_songs(
439 &self,
440 song_id: &str,
441 count: usize,
442 ) -> Result<Vec<SubsonicSong>, SubsonicError> {
443 let count_str = count.to_string();
444 let resp = self.get_with_params(
445 "getSimilarSongs2",
446 &[("id", song_id), ("count", &count_str)],
447 )?;
448 Ok(resp.similar_songs2.and_then(|s| s.song).unwrap_or_default())
449 }
450
451 pub fn get_top_songs(
453 &self,
454 artist_name: &str,
455 count: usize,
456 ) -> Result<Vec<SubsonicSong>, SubsonicError> {
457 let count_str = count.to_string();
458 let resp = self.get_with_params(
459 "getTopSongs",
460 &[("artist", artist_name), ("count", &count_str)],
461 )?;
462 Ok(resp.top_songs.and_then(|t| t.song).unwrap_or_default())
463 }
464
465 pub fn get_playlists(&self) -> Result<Vec<SubsonicPlaylist>, SubsonicError> {
469 let resp = self.get("getPlaylists")?;
470 Ok(resp.playlists.map(|p| p.playlist).unwrap_or_default())
471 }
472
473 pub fn get_playlist(&self, id: &str) -> Result<SubsonicPlaylistFull, SubsonicError> {
475 let resp = self.get_with_params("getPlaylist", &[("id", id)])?;
476 resp.playlist.ok_or(SubsonicError::BadResponse)
477 }
478
479 pub fn create_playlist(
488 &self,
489 playlist_id: Option<&str>,
490 name: &str,
491 song_ids: &[String],
492 ) -> Result<Option<SubsonicPlaylistFull>, SubsonicError> {
493 let url = format!("{}/rest/createPlaylist", self.auth.base_url);
494 let mut params = self.auth_params()?;
495 match playlist_id {
496 Some(id) => {
497 params.insert("playlistId".into(), id.to_string());
498 params.insert("name".into(), name.to_string());
501 }
502 None => {
503 params.insert("name".into(), name.to_string());
504 }
505 }
506
507 let mut query: Vec<(String, String)> = params.into_iter().collect();
509 for id in song_ids {
510 query.push(("songId".into(), id.clone()));
511 }
512
513 let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
514 let inner = resp.subsonic_response;
515 if inner.status != "ok" {
516 if let Some(err) = inner.error {
517 return Err(SubsonicError::Api {
518 code: err.code,
519 message: err.message,
520 });
521 }
522 return Err(SubsonicError::BadResponse);
523 }
524 Ok(inner.playlist)
528 }
529
530 pub fn update_playlist(
532 &self,
533 id: &str,
534 name: Option<&str>,
535 comment: Option<&str>,
536 public: Option<bool>,
537 ) -> Result<(), SubsonicError> {
538 let mut extra: Vec<(&str, String)> = vec![("playlistId", id.to_string())];
539 if let Some(name) = name {
540 extra.push(("name", name.to_string()));
541 }
542 if let Some(comment) = comment {
543 extra.push(("comment", comment.to_string()));
544 }
545 if let Some(public) = public {
546 extra.push(("public", public.to_string()));
547 }
548 let borrowed: Vec<(&str, &str)> = extra.iter().map(|(k, v)| (*k, v.as_str())).collect();
549 self.get_with_params("updatePlaylist", &borrowed)?;
550 Ok(())
551 }
552
553 pub fn delete_playlist(&self, id: &str) -> Result<(), SubsonicError> {
554 self.get_with_params("deletePlaylist", &[("id", id)])?;
555 Ok(())
556 }
557
558 pub fn base_url(&self) -> &str {
560 &self.auth.base_url
561 }
562}
563
564#[derive(Debug, Deserialize)]
567struct SubsonicResponseWrapper {
568 #[serde(rename = "subsonic-response")]
569 subsonic_response: SubsonicResponse,
570}
571
572#[derive(Debug, Deserialize)]
573#[serde(rename_all = "camelCase")]
574struct SubsonicResponse {
575 status: String,
576 error: Option<SubsonicApiError>,
577 artists: Option<SubsonicArtists>,
578 album: Option<SubsonicAlbumFull>,
579 album_list2: Option<SubsonicAlbumList>,
580 search_result3: Option<SubsonicSearchResult>,
581 starred2: Option<SubsonicStarred>,
582 shares: Option<SubsonicShares>,
583 similar_songs2: Option<SubsonicSimilarSongs>,
584 top_songs: Option<SubsonicTopSongs>,
585 playlists: Option<SubsonicPlaylists>,
586 playlist: Option<SubsonicPlaylistFull>,
587 scan_status: Option<SubsonicScanStatus>,
588}
589
590#[derive(Debug, Deserialize)]
591struct SubsonicScanStatus {
592 count: Option<u64>,
593}
594
595#[derive(Debug, Deserialize)]
596struct SubsonicApiError {
597 code: i32,
598 message: String,
599}
600
601#[derive(Debug, Deserialize)]
602struct SubsonicArtists {
603 index: Vec<SubsonicArtistIndex>,
604}
605
606#[derive(Debug, Deserialize)]
607struct SubsonicArtistIndex {
608 artist: Vec<SubsonicArtist>,
609}
610
611#[derive(Debug, Clone, Deserialize)]
612#[serde(rename_all = "camelCase")]
613pub struct SubsonicArtist {
614 pub id: String,
615 pub name: String,
616 pub album_count: Option<i32>,
617 #[serde(default, deserialize_with = "non_empty")]
620 pub music_brainz_id: Option<String>,
621 #[serde(default, deserialize_with = "non_empty")]
622 pub sort_name: Option<String>,
623}
624
625#[derive(Debug, Clone, Deserialize)]
626#[serde(rename_all = "camelCase")]
627pub struct SubsonicAlbum {
628 pub id: String,
629 pub name: String,
630 pub artist: Option<String>,
631 pub artist_id: Option<String>,
632 pub song_count: Option<i32>,
633 pub year: Option<i32>,
634 pub genre: Option<String>,
635 pub created: Option<String>,
636 #[serde(default, deserialize_with = "non_empty")]
639 pub music_brainz_id: Option<String>,
640 #[serde(default, deserialize_with = "non_empty")]
641 pub sort_name: Option<String>,
642 #[serde(default)]
643 pub record_labels: Vec<SubsonicName>,
644}
645
646#[derive(Debug, Clone, Deserialize)]
649pub struct SubsonicName {
650 pub name: String,
651}
652
653#[derive(Debug, Clone, Deserialize)]
654#[serde(rename_all = "camelCase")]
655pub struct SubsonicAlbumFull {
656 pub id: String,
657 pub name: String,
658 pub artist: Option<String>,
659 pub artist_id: Option<String>,
660 pub year: Option<i32>,
661 pub genre: Option<String>,
662 pub song_count: Option<i32>,
663 pub created: Option<String>,
664 #[serde(default, deserialize_with = "non_empty")]
665 pub music_brainz_id: Option<String>,
666 #[serde(default, deserialize_with = "non_empty")]
667 pub sort_name: Option<String>,
668 #[serde(default)]
669 pub record_labels: Vec<SubsonicName>,
670 #[serde(default)]
671 pub song: Vec<SubsonicSong>,
672}
673
674#[derive(Debug, Clone, Deserialize)]
675#[serde(rename_all = "camelCase")]
676pub struct SubsonicSong {
677 pub id: String,
678 pub title: String,
679 pub album: Option<String>,
680 pub artist: Option<String>,
681 pub track: Option<i32>,
682 pub disc_number: Option<i32>,
683 pub year: Option<i32>,
684 pub genre: Option<String>,
685 pub duration: Option<i64>,
686 pub bit_rate: Option<i32>,
687 pub suffix: Option<String>,
688 pub content_type: Option<String>,
689 pub album_id: Option<String>,
690 pub artist_id: Option<String>,
691 pub sampling_rate: Option<i32>,
694 pub bit_depth: Option<i32>,
695 pub channel_count: Option<i32>,
696 #[serde(default, deserialize_with = "non_empty")]
697 pub music_brainz_id: Option<String>,
698}
699
700#[derive(Debug, Deserialize)]
701struct SubsonicAlbumList {
702 #[serde(default)]
703 album: Vec<SubsonicAlbum>,
704}
705
706#[derive(Debug, Default, Deserialize)]
707pub struct SubsonicSearchResult {
708 #[serde(default)]
709 pub artist: Vec<SubsonicArtist>,
710 #[serde(default)]
711 pub album: Vec<SubsonicAlbum>,
712 #[serde(default)]
713 pub song: Vec<SubsonicSong>,
714}
715
716#[derive(Debug, Default, Deserialize)]
717pub struct SubsonicStarred {
718 #[serde(default)]
719 pub song: Vec<SubsonicSong>,
720 #[serde(default)]
721 pub album: Vec<SubsonicAlbum>,
722 #[serde(default)]
723 pub artist: Vec<SubsonicArtist>,
724}
725
726#[derive(Debug, Deserialize)]
727pub struct SubsonicSimilarSongs {
728 pub song: Option<Vec<SubsonicSong>>,
729}
730
731#[derive(Debug, Deserialize)]
732pub struct SubsonicTopSongs {
733 pub song: Option<Vec<SubsonicSong>>,
734}
735
736#[derive(Debug, Default, Deserialize)]
737struct SubsonicPlaylists {
738 #[serde(default)]
739 playlist: Vec<SubsonicPlaylist>,
740}
741
742#[derive(Debug, Clone, Deserialize)]
744#[serde(rename_all = "camelCase")]
745pub struct SubsonicPlaylist {
746 pub id: String,
747 pub name: String,
748 pub comment: Option<String>,
749 pub owner: Option<String>,
750 #[serde(default)]
751 pub public: bool,
752 pub song_count: Option<i64>,
753 pub duration: Option<i64>,
754 pub created: Option<String>,
755 pub changed: Option<String>,
756}
757
758#[derive(Debug, Clone, Deserialize)]
759#[serde(rename_all = "camelCase")]
760pub struct SubsonicPlaylistFull {
761 #[serde(flatten)]
762 pub playlist: SubsonicPlaylist,
763 #[serde(default)]
764 pub entry: Vec<SubsonicSong>,
765}
766
767#[derive(Debug, Deserialize)]
768struct SubsonicShares {
769 #[serde(default)]
770 share: Vec<SubsonicShare>,
771}
772
773#[derive(Debug, Clone, Deserialize)]
774#[serde(rename_all = "camelCase")]
775pub struct SubsonicShare {
776 pub id: String,
777 pub url: Option<String>,
778 pub description: Option<String>,
779 pub username: Option<String>,
780 pub created: Option<String>,
781 pub expires: Option<String>,
782 pub visit_count: Option<i64>,
783}
784
785fn non_empty<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
792 Ok(Option::<String>::deserialize(d)?.filter(|s| !s.is_empty()))
793}
794
795fn random_salt() -> Result<String, getrandom::Error> {
802 let mut buf = [0u8; 12];
803 getrandom::fill(&mut buf)?;
804 Ok(buf.iter().map(|b| format!("{:02x}", b)).collect())
805}
806
807#[cfg(test)]
808mod tests {
809 use super::*;
810
811 #[test]
814 fn test_deserialize_subsonic_song() {
815 let json = r#"{
816 "id": "42",
817 "title": "Space Oddity",
818 "album": "Space Oddity",
819 "artist": "David Bowie",
820 "track": 1,
821 "discNumber": 1,
822 "year": 1969,
823 "genre": "Rock",
824 "duration": 314,
825 "bitRate": 320,
826 "suffix": "mp3",
827 "contentType": "audio/mpeg",
828 "albumId": "7",
829 "artistId": "3"
830 }"#;
831
832 let song: SubsonicSong = serde_json::from_str(json).unwrap();
833
834 assert_eq!(song.id, "42");
835 assert_eq!(song.title, "Space Oddity");
836 assert_eq!(song.album.as_deref(), Some("Space Oddity"));
837 assert_eq!(song.artist.as_deref(), Some("David Bowie"));
838 assert_eq!(song.track, Some(1));
839 assert_eq!(song.disc_number, Some(1));
840 assert_eq!(song.year, Some(1969));
841 assert_eq!(song.genre.as_deref(), Some("Rock"));
842 assert_eq!(song.duration, Some(314));
843 assert_eq!(song.bit_rate, Some(320));
844 assert_eq!(song.suffix.as_deref(), Some("mp3"));
845 assert_eq!(song.content_type.as_deref(), Some("audio/mpeg"));
846 assert_eq!(song.album_id.as_deref(), Some("7"));
847 assert_eq!(song.artist_id.as_deref(), Some("3"));
848 }
849
850 #[test]
854 fn opensubsonic_quality_fields_are_read() {
855 let json = r#"{
856 "id": "000XtGC7jsWEbOjDsZi4Xw",
857 "title": "Anguish",
858 "suffix": "flac",
859 "bitRate": 913,
860 "samplingRate": 44100,
861 "bitDepth": 16,
862 "channelCount": 2
863 }"#;
864
865 let song: SubsonicSong = serde_json::from_str(json).unwrap();
866
867 assert_eq!(song.sampling_rate, Some(44100));
868 assert_eq!(song.bit_depth, Some(16));
869 assert_eq!(song.channel_count, Some(2));
870 }
871
872 #[test]
875 fn a_plain_subsonic_song_has_no_quality_figures() {
876 let json = r#"{"id": "1", "title": "Track", "bitRate": 320}"#;
877 let song: SubsonicSong = serde_json::from_str(json).unwrap();
878
879 assert_eq!(song.sampling_rate, None);
880 assert_eq!(song.bit_depth, None);
881 assert_eq!(song.channel_count, None);
882 }
883
884 #[test]
885 fn test_deserialize_subsonic_song_optional_fields_absent() {
886 let json = r#"{"id": "99", "title": "Minimal Track"}"#;
888
889 let song: SubsonicSong = serde_json::from_str(json).unwrap();
890
891 assert_eq!(song.id, "99");
892 assert_eq!(song.title, "Minimal Track");
893 assert!(song.album.is_none());
894 assert!(song.artist.is_none());
895 assert!(song.track.is_none());
896 assert!(song.disc_number.is_none());
897 assert!(song.year.is_none());
898 assert!(song.duration.is_none());
899 assert!(song.bit_rate.is_none());
900 }
901
902 #[test]
905 fn test_deserialize_album_list() {
906 let json = r#"{
907 "subsonic-response": {
908 "status": "ok",
909 "version": "1.16.1",
910 "albumList2": {
911 "album": [
912 {
913 "id": "1",
914 "name": "Abbey Road",
915 "artist": "The Beatles",
916 "artistId": "10",
917 "songCount": 17,
918 "year": 1969,
919 "genre": "Rock",
920 "created": "2020-01-01T00:00:00"
921 },
922 {
923 "id": "2",
924 "name": "Led Zeppelin IV",
925 "artist": "Led Zeppelin",
926 "artistId": "11",
927 "songCount": 8,
928 "year": 1971,
929 "genre": "Hard Rock",
930 "created": "2020-01-02T00:00:00"
931 }
932 ]
933 }
934 }
935 }"#;
936
937 let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
938 let album_list = wrapper
939 .subsonic_response
940 .album_list2
941 .expect("album_list2 should be present");
942
943 assert_eq!(album_list.album.len(), 2);
944
945 let first = &album_list.album[0];
946 assert_eq!(first.id, "1");
947 assert_eq!(first.name, "Abbey Road");
948 assert_eq!(first.artist.as_deref(), Some("The Beatles"));
949 assert_eq!(first.artist_id.as_deref(), Some("10"));
950 assert_eq!(first.song_count, Some(17));
951 assert_eq!(first.year, Some(1969));
952
953 let second = &album_list.album[1];
954 assert_eq!(second.id, "2");
955 assert_eq!(second.name, "Led Zeppelin IV");
956 assert_eq!(second.song_count, Some(8));
957 }
958
959 #[test]
962 fn test_auth_params_format() {
963 let client = SubsonicClient::new("http://localhost:4533", "alice", "secret");
964 let params = client.auth_params().unwrap();
965
966 assert!(params.contains_key("u"), "missing 'u' param");
968 assert!(params.contains_key("t"), "missing 't' param");
969 assert!(params.contains_key("s"), "missing 's' param");
970 assert!(params.contains_key("v"), "missing 'v' param");
971 assert!(params.contains_key("c"), "missing 'c' param");
972 assert!(params.contains_key("f"), "missing 'f' param");
973 assert_eq!(params.len(), 6);
974
975 assert_eq!(params["u"], "alice");
976 assert_eq!(params["v"], "1.16.1");
977 assert_eq!(params["c"], "koan");
978 assert_eq!(params["f"], "json");
979 }
980
981 #[test]
982 fn test_auth_params_over_https_send_the_hex_password() {
983 let client = SubsonicClient::new("https://koan.example", "alice", "hi");
984 let params = client.auth_params().unwrap();
985 assert_eq!(params["p"], "enc:6869");
986 assert!(!params.contains_key("t") && !params.contains_key("s"));
987 }
988
989 #[test]
990 fn test_auth_params_token_is_md5_of_password_plus_salt() {
991 let client = SubsonicClient::new("http://localhost:4533", "bob", "letmein");
992 let params = client.auth_params().unwrap();
993
994 let salt = ¶ms["s"];
995 let token = ¶ms["t"];
996
997 let expected = format!("{:x}", md5::compute(format!("letmein{}", salt)));
999 assert_eq!(token, &expected);
1000 }
1001
1002 #[test]
1003 fn test_auth_params_salt_is_different_each_call() {
1004 let client = SubsonicClient::new("http://localhost:4533", "user", "pass");
1005 let params1 = client.auth_params().unwrap();
1006 let params2 = client.auth_params().unwrap();
1007
1008 assert_ne!(params1["s"], params2["s"], "salt should be random per call");
1011 }
1012
1013 #[test]
1016 fn test_stream_url_has_auth() {
1017 let client = SubsonicClient::new("http://myserver:4533", "user", "pass");
1018 let url = client.stream_url("track-123").unwrap();
1019
1020 assert!(url.contains("track-123"), "url must include the track id");
1021 assert!(url.contains("u=user"), "url must include username param");
1022 assert!(url.contains("v=1.16.1"), "url must include api version");
1023 assert!(url.contains("c=koan"), "url must include client name");
1024 assert!(url.contains("f=json"), "url must include format param");
1025 assert!(url.contains("/rest/stream"), "url must target /rest/stream");
1026 assert!(
1027 url.starts_with("http://myserver:4533"),
1028 "url must use the configured base_url"
1029 );
1030 }
1031
1032 #[test]
1033 fn test_stream_url_base_url_trailing_slash_normalised() {
1034 let client_with_slash = SubsonicClient::new("http://myserver:4533/", "u", "p");
1036 let client_no_slash = SubsonicClient::new("http://myserver:4533", "u", "p");
1037
1038 let url_with = client_with_slash.stream_url("1").unwrap();
1039 let url_without = client_no_slash.stream_url("1").unwrap();
1040
1041 assert!(
1043 url_with.contains("/rest/stream"),
1044 "should not have double slash"
1045 );
1046 assert!(!url_with.contains("//rest"), "should not have double slash");
1047 assert_eq!(
1049 url_with.split('?').next(),
1050 url_without.split('?').next(),
1051 "path segment should be identical regardless of trailing slash"
1052 );
1053 }
1054
1055 #[test]
1058 fn test_deserialize_album_full_with_songs() {
1059 let json = r#"{
1060 "id": "5",
1061 "name": "Kind of Blue",
1062 "artist": "Miles Davis",
1063 "artistId": "20",
1064 "year": 1959,
1065 "genre": "Jazz",
1066 "songCount": 5,
1067 "created": "2021-06-01T00:00:00",
1068 "song": [
1069 {"id": "101", "title": "So What"},
1070 {"id": "102", "title": "Freddie Freeloader"},
1071 {"id": "103", "title": "Blue in Green"}
1072 ]
1073 }"#;
1074
1075 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1076
1077 assert_eq!(album.id, "5");
1078 assert_eq!(album.name, "Kind of Blue");
1079 assert_eq!(album.artist.as_deref(), Some("Miles Davis"));
1080 assert_eq!(album.year, Some(1959));
1081 assert_eq!(album.song.len(), 3);
1082 assert_eq!(album.song[0].title, "So What");
1083 assert_eq!(album.song[2].id, "103");
1084 }
1085
1086 #[test]
1087 fn test_deserialize_album_full_empty_song_list() {
1088 let json = r#"{"id": "9", "name": "No Tracks Yet"}"#;
1090
1091 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1092
1093 assert_eq!(album.id, "9");
1094 assert!(album.song.is_empty(), "song list should default to empty");
1095 }
1096
1097 #[test]
1100 fn test_deserialize_search_result_mixed() {
1101 let json = r#"{
1102 "artist": [{"id": "1", "name": "Artist One"}],
1103 "album": [{"id": "2", "name": "Album One"}],
1104 "song": [{"id": "3", "title": "Song One"}]
1105 }"#;
1106
1107 let result: SubsonicSearchResult = serde_json::from_str(json).unwrap();
1108
1109 assert_eq!(result.artist.len(), 1);
1110 assert_eq!(result.artist[0].name, "Artist One");
1111 assert_eq!(result.album.len(), 1);
1112 assert_eq!(result.album[0].name, "Album One");
1113 assert_eq!(result.song.len(), 1);
1114 assert_eq!(result.song[0].title, "Song One");
1115 }
1116
1117 #[test]
1118 fn empty_opensubsonic_ids_are_absent() {
1119 let json = r#"{"id": "1", "title": "T", "musicBrainzId": ""}"#;
1120 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1121 assert_eq!(song.music_brainz_id, None);
1122
1123 let json = r#"{"id": "2", "name": "A", "musicBrainzId": "", "sortName": ""}"#;
1124 let album: SubsonicAlbum = serde_json::from_str(json).unwrap();
1125 assert_eq!((album.music_brainz_id, album.sort_name), (None, None));
1126
1127 let json = r#"{"id": "3", "name": "A", "musicBrainzId": "mb-1"}"#;
1128 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1129 assert_eq!(album.music_brainz_id.as_deref(), Some("mb-1"));
1130 assert_eq!(album.sort_name, None);
1131 }
1132
1133 #[test]
1134 fn test_deserialize_scan_status_count() {
1135 let json = r#"{"subsonic-response":{"status":"ok","scanStatus":{"scanning":false,"count":49700}}}"#;
1136 let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
1137 let status = wrapper.subsonic_response.scan_status.unwrap();
1138 assert_eq!(status.count, Some(49_700));
1139 }
1140
1141 #[test]
1142 fn test_deserialize_search_result_defaults_to_empty() {
1143 let result: SubsonicSearchResult = serde_json::from_str("{}").unwrap();
1145
1146 assert!(result.artist.is_empty());
1147 assert!(result.album.is_empty());
1148 assert!(result.song.is_empty());
1149 }
1150}