1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::OnceLock;
4
5use serde::Deserialize;
6use thiserror::Error;
7
8use super::download::{self, DownloadError};
9
10const API_VERSION: &str = "1.16.1";
11const CLIENT_NAME: &str = "koan";
12const PLAYBACK_REPORT_EXTENSION: &str = "playbackReport";
13
14#[derive(Debug, Error)]
15pub enum SubsonicError {
16 #[error("http error: {0}")]
17 Http(reqwest::Error),
18 #[error("api error: {code} — {message}")]
19 Api { code: i32, message: String },
20 #[error("unexpected response format")]
21 BadResponse,
22 #[error("io error: {0}")]
23 Io(#[from] std::io::Error),
24 #[error("download error: {0}")]
25 Download(#[from] DownloadError),
26 #[error("entropy source unavailable: {0}")]
27 Entropy(#[from] getrandom::Error),
28}
29
30impl From<reqwest::Error> for SubsonicError {
34 fn from(e: reqwest::Error) -> Self {
35 Self::Http(e.without_url())
36 }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct SubsonicAuth {
47 pub base_url: String,
48 pub username: String,
49 pub password: String,
50}
51
52impl SubsonicAuth {
53 pub fn new(base_url: &str, username: &str, password: &str) -> Self {
54 Self {
55 base_url: base_url.trim_end_matches('/').to_string(),
56 username: username.to_string(),
57 password: password.to_string(),
58 }
59 }
60
61 fn params(&self) -> Result<HashMap<String, String>, SubsonicError> {
68 let mut params = HashMap::new();
69 params.insert("u".into(), self.username.clone());
70 if self.base_url.starts_with("https://") {
71 let hex: String = self.password.bytes().map(|b| format!("{b:02x}")).collect();
72 params.insert("p".into(), format!("enc:{hex}"));
73 } else {
74 let salt = random_salt()?;
75 let token = format!("{:x}", md5::compute(format!("{}{}", self.password, salt)));
76 params.insert("t".into(), token);
77 params.insert("s".into(), salt);
78 }
79 params.insert("v".into(), API_VERSION.into());
80 params.insert("c".into(), CLIENT_NAME.into());
81 params.insert("f".into(), "json".into());
82 Ok(params)
83 }
84
85 pub fn query(&self) -> Result<String, SubsonicError> {
87 Ok(self
88 .params()?
89 .iter()
90 .map(|(k, v)| format!("{}={}", k, v))
91 .collect::<Vec<_>>()
92 .join("&"))
93 }
94
95 pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
97 Ok(format!(
98 "{}/rest/stream?id={}&{}",
99 self.base_url,
100 track_id,
101 self.query()?
102 ))
103 }
104}
105
106pub struct SubsonicClient {
113 auth: SubsonicAuth,
114 http: reqwest::blocking::Client,
115 downloader: reqwest::blocking::Client,
116 outage: download::Outage,
118 playback_report: OnceLock<bool>,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum PlaybackReportState {
125 Playing,
126 Paused,
127 Stopped,
128}
129
130impl PlaybackReportState {
131 fn as_str(self) -> &'static str {
132 match self {
133 Self::Playing => "playing",
134 Self::Paused => "paused",
135 Self::Stopped => "stopped",
136 }
137 }
138}
139
140impl SubsonicClient {
141 pub fn new(base_url: &str, username: &str, password: &str) -> Self {
142 Self::from_auth(SubsonicAuth::new(base_url, username, password))
143 }
144
145 pub fn from_auth(auth: SubsonicAuth) -> Self {
146 Self {
147 auth,
148 http: download::api_client().unwrap_or_else(|e| {
149 log::warn!("falling back to default HTTP client: {}", e);
150 reqwest::blocking::Client::new()
151 }),
152 downloader: download::download_client().unwrap_or_else(|e| {
153 log::warn!("falling back to default download client: {}", e);
154 reqwest::blocking::Client::new()
155 }),
156 outage: download::Outage::default(),
157 playback_report: OnceLock::new(),
158 }
159 }
160
161 pub fn outage(&self) -> &download::Outage {
163 &self.outage
164 }
165
166 fn auth_params(&self) -> Result<HashMap<String, String>, SubsonicError> {
167 self.auth.params()
168 }
169
170 fn get(&self, endpoint: &str) -> Result<SubsonicResponse, SubsonicError> {
172 self.get_with_params(endpoint, &[])
173 }
174
175 fn get_with_params(
176 &self,
177 endpoint: &str,
178 extra: &[(&str, &str)],
179 ) -> Result<SubsonicResponse, SubsonicError> {
180 let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
181 let mut params = self.auth_params()?;
182 for (k, v) in extra {
183 params.insert((*k).to_string(), (*v).to_string());
184 }
185
186 let resp: SubsonicResponseWrapper = self.http.get(&url).query(¶ms).send()?.json()?;
187
188 let inner = resp.subsonic_response;
189 if inner.status != "ok" {
190 if let Some(err) = inner.error {
191 return Err(SubsonicError::Api {
192 code: err.code,
193 message: err.message,
194 });
195 }
196 return Err(SubsonicError::BadResponse);
197 }
198
199 Ok(inner)
200 }
201
202 fn reject_error_body(resp: &reqwest::blocking::Response) -> Result<(), SubsonicError> {
209 let is_document = resp
210 .headers()
211 .get(reqwest::header::CONTENT_TYPE)
212 .and_then(|v| v.to_str().ok())
213 .is_some_and(|ct| ct.contains("json") || ct.contains("xml"));
214 if is_document {
215 return Err(SubsonicError::BadResponse);
216 }
217 if !resp.status().is_success() {
218 return Err(SubsonicError::BadResponse);
219 }
220 Ok(())
221 }
222
223 pub fn get_cover_art(&self, id: &str, size: Option<u32>) -> Result<Vec<u8>, SubsonicError> {
229 let url = format!("{}/rest/getCoverArt", self.base_url());
230 let mut params = self.auth_params()?;
231 params.insert("id".into(), id.to_string());
232 if let Some(px) = size {
233 params.insert("size".into(), px.to_string());
234 }
235
236 let resp = self.http.get(&url).query(¶ms).send()?;
237 Self::reject_error_body(&resp)?;
238 Ok(resp.bytes()?.to_vec())
239 }
240
241 pub fn ping(&self) -> Result<(), SubsonicError> {
243 self.get("ping")?;
244 Ok(())
245 }
246
247 pub fn server_type(&self) -> Result<Option<String>, SubsonicError> {
249 Ok(self.get("ping")?.server_type)
250 }
251
252 pub fn profile(&self) -> Result<crate::remote::profile::ServerProfile, SubsonicError> {
256 let ping = self.get("ping")?;
257 let extensions = if ping.open_subsonic {
258 self.get("getOpenSubsonicExtensions")
259 .ok()
260 .and_then(|r| r.open_subsonic_extensions)
261 .unwrap_or_default()
262 .into_iter()
263 .map(|e| (e.name, e.versions))
264 .collect()
265 } else {
266 Vec::new()
267 };
268 Ok(crate::remote::profile::ServerProfile {
269 kind: ping.server_type,
270 version: ping.server_version,
271 open_subsonic: ping.open_subsonic,
272 extensions,
273 })
274 }
275
276 pub fn get_artists(&self) -> Result<Vec<SubsonicArtist>, SubsonicError> {
278 let resp = self.get("getArtists")?;
279 let artists_data = resp.artists.ok_or(SubsonicError::BadResponse)?;
280 let mut all = Vec::new();
281 for index in artists_data.index {
282 all.extend(index.artist);
283 }
284 Ok(all)
285 }
286
287 pub fn get_album(&self, id: &str) -> Result<SubsonicAlbumFull, SubsonicError> {
289 let resp = self.get_with_params("getAlbum", &[("id", id)])?;
290 resp.album.ok_or(SubsonicError::BadResponse)
291 }
292
293 pub fn get_album_list(
295 &self,
296 list_type: &str,
297 size: u32,
298 offset: u32,
299 ) -> Result<Vec<SubsonicAlbum>, SubsonicError> {
300 let size_str = size.to_string();
301 let offset_str = offset.to_string();
302 let resp = self.get_with_params(
303 "getAlbumList2",
304 &[
305 ("type", list_type),
306 ("size", &size_str),
307 ("offset", &offset_str),
308 ],
309 )?;
310 Ok(resp.album_list2.map(|al| al.album).unwrap_or_default())
311 }
312
313 pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
315 self.auth.stream_url(track_id)
316 }
317
318 pub fn stream_url_template(&self, track_id: &str) -> String {
320 format!("{}/rest/stream?id={}", self.auth.base_url, track_id)
321 }
322
323 pub fn download(&self, track_id: &str, dest: &Path) -> Result<(), SubsonicError> {
325 self.fetch_to_file("download", track_id, dest, None, |_, _| {})
326 }
327
328 pub fn download_with_progress(
337 &self,
338 track_id: &str,
339 dest: &Path,
340 cancelled: &dyn Fn() -> bool,
341 on_progress: impl Fn(u64, u64),
342 ) -> Result<(), SubsonicError> {
343 let patience = download::Patience {
344 outage: &self.outage,
345 cancelled,
346 };
347 self.fetch_to_file("download", track_id, dest, Some(patience), on_progress)
348 }
349
350 pub fn stream_to_file(
356 &self,
357 track_id: &str,
358 dest: &Path,
359 on_progress: impl Fn(u64, u64),
360 ) -> Result<(), SubsonicError> {
361 self.fetch_to_file("stream", track_id, dest, None, on_progress)
362 }
363
364 fn fetch_to_file(
365 &self,
366 endpoint: &str,
367 track_id: &str,
368 dest: &Path,
369 patience: Option<download::Patience<'_>>,
370 on_progress: impl Fn(u64, u64),
371 ) -> Result<(), SubsonicError> {
372 let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
373 download::download_with_retries(
374 dest,
375 download::DEFAULT_ATTEMPTS,
376 patience,
377 || {
378 let mut params = self
380 .auth_params()
381 .map_err(|e| download::DownloadError::Request(e.to_string()))?;
382 params.insert("id".into(), track_id.to_string());
383 Ok(self.downloader.get(&url).query(¶ms))
384 },
385 on_progress,
386 )?;
387 Ok(())
388 }
389
390 pub fn all_songs_page(
396 &self,
397 size: u32,
398 offset: u32,
399 ) -> Result<Vec<SubsonicSong>, SubsonicError> {
400 let size = size.to_string();
401 let offset = offset.to_string();
402 let resp = self.get_with_params(
403 "search3",
404 &[
405 ("query", ""),
406 ("artistCount", "0"),
407 ("albumCount", "0"),
408 ("songCount", &size),
409 ("songOffset", &offset),
410 ],
411 )?;
412 Ok(resp.search_result3.map(|r| r.song).unwrap_or_default())
413 }
414
415 pub fn song_count(&self) -> Result<Option<u64>, SubsonicError> {
418 let resp = self.get("getScanStatus")?;
419 Ok(resp.scan_status.and_then(|s| s.count))
420 }
421
422 pub fn library_modified(&self, since: Option<i64>) -> Result<Option<i64>, SubsonicError> {
427 let since = since.map(|v| v.to_string());
428 let params: Vec<(&str, &str)> = since
429 .as_deref()
430 .map(|v| vec![("ifModifiedSince", v)])
431 .unwrap_or_default();
432 let resp = self.get_with_params("getIndexes", ¶ms)?;
433 Ok(resp.indexes.and_then(|i| i.last_modified))
434 }
435
436 pub fn search(&self, query: &str) -> Result<SubsonicSearchResult, SubsonicError> {
438 let resp = self.get_with_params("search3", &[("query", query)])?;
439 Ok(resp.search_result3.unwrap_or_default())
440 }
441
442 pub fn scrobble(&self, track_id: &str, heard_at_ms: u64) -> Result<(), SubsonicError> {
445 let at = heard_at_ms.to_string();
446 self.get_with_params(
447 "scrobble",
448 &[("id", track_id), ("submission", "true"), ("time", &at)],
449 )?;
450 Ok(())
451 }
452
453 pub fn report_playback(
462 &self,
463 track_id: &str,
464 state: PlaybackReportState,
465 position_ms: u64,
466 ) -> Result<(), SubsonicError> {
467 if self.supports_playback_report() {
468 let position = position_ms.to_string();
469 self.get_with_params(
470 "reportPlayback",
471 &[
472 ("mediaId", track_id),
473 ("mediaType", "song"),
474 ("positionMs", &position),
475 ("state", state.as_str()),
476 ("ignoreScrobble", "true"),
477 ],
478 )?;
479 return Ok(());
480 }
481 if state == PlaybackReportState::Playing {
482 let position = (position_ms / 1000).to_string();
483 self.get_with_params(
484 "scrobble",
485 &[
486 ("id", track_id),
487 ("submission", "false"),
488 ("position", &position),
489 ],
490 )?;
491 }
492 Ok(())
493 }
494
495 fn supports_playback_report(&self) -> bool {
499 if let Some(&known) = self.playback_report.get() {
500 return known;
501 }
502 let supported = match self.get("getOpenSubsonicExtensions") {
503 Ok(resp) => resp.has_extension(PLAYBACK_REPORT_EXTENSION),
504 Err(SubsonicError::Http(e)) if e.is_connect() || e.is_timeout() => return false,
505 Err(_) => false,
506 };
507 *self.playback_report.get_or_init(|| supported)
508 }
509
510 pub fn star(&self, track_id: &str) -> Result<(), SubsonicError> {
512 self.get_with_params("star", &[("id", track_id)])?;
513 Ok(())
514 }
515
516 pub fn unstar(&self, track_id: &str) -> Result<(), SubsonicError> {
518 self.get_with_params("unstar", &[("id", track_id)])?;
519 Ok(())
520 }
521
522 pub fn get_starred_all(&self) -> Result<SubsonicStarred, SubsonicError> {
527 let resp = self.get("getStarred2")?;
528 Ok(resp.starred2.unwrap_or_default())
529 }
530
531 pub fn star_album(&self, album_id: &str) -> Result<(), SubsonicError> {
534 self.get_with_params("star", &[("albumId", album_id)])?;
535 Ok(())
536 }
537
538 pub fn unstar_album(&self, album_id: &str) -> Result<(), SubsonicError> {
539 self.get_with_params("unstar", &[("albumId", album_id)])?;
540 Ok(())
541 }
542
543 pub fn star_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
544 self.get_with_params("star", &[("artistId", artist_id)])?;
545 Ok(())
546 }
547
548 pub fn unstar_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
549 self.get_with_params("unstar", &[("artistId", artist_id)])?;
550 Ok(())
551 }
552
553 pub fn create_share(
556 &self,
557 ids: &[&str],
558 description: Option<&str>,
559 ) -> Result<SubsonicShare, SubsonicError> {
560 let url = format!("{}/rest/createShare", self.auth.base_url);
561 let mut params = self.auth_params()?;
562 if let Some(desc) = description {
563 params.insert("description".into(), desc.to_string());
564 }
565
566 let mut query: Vec<(String, String)> = params.into_iter().collect();
568 for id in ids {
569 query.push(("id".into(), (*id).to_string()));
570 }
571
572 let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
573
574 let inner = resp.subsonic_response;
575 if inner.status != "ok" {
576 if let Some(err) = inner.error {
577 return Err(SubsonicError::Api {
578 code: err.code,
579 message: err.message,
580 });
581 }
582 return Err(SubsonicError::BadResponse);
583 }
584
585 inner
586 .shares
587 .and_then(|s| s.share.into_iter().next())
588 .ok_or(SubsonicError::BadResponse)
589 }
590
591 pub fn get_similar_songs(
594 &self,
595 song_id: &str,
596 count: usize,
597 ) -> Result<Vec<SubsonicSong>, SubsonicError> {
598 let count_str = count.to_string();
599 let resp = self.get_with_params(
600 "getSimilarSongs2",
601 &[("id", song_id), ("count", &count_str)],
602 )?;
603 Ok(resp.similar_songs2.and_then(|s| s.song).unwrap_or_default())
604 }
605
606 pub fn get_playlists(&self) -> Result<Vec<SubsonicPlaylist>, SubsonicError> {
610 let resp = self.get("getPlaylists")?;
611 Ok(resp.playlists.map(|p| p.playlist).unwrap_or_default())
612 }
613
614 pub fn get_playlist(&self, id: &str) -> Result<SubsonicPlaylistFull, SubsonicError> {
616 let resp = self.get_with_params("getPlaylist", &[("id", id)])?;
617 resp.playlist.ok_or(SubsonicError::BadResponse)
618 }
619
620 pub fn create_playlist(
629 &self,
630 playlist_id: Option<&str>,
631 name: &str,
632 song_ids: &[String],
633 ) -> Result<Option<SubsonicPlaylistFull>, SubsonicError> {
634 let url = format!("{}/rest/createPlaylist", self.auth.base_url);
635 let mut params = self.auth_params()?;
636 match playlist_id {
637 Some(id) => {
638 params.insert("playlistId".into(), id.to_string());
639 params.insert("name".into(), name.to_string());
642 }
643 None => {
644 params.insert("name".into(), name.to_string());
645 }
646 }
647
648 let mut query: Vec<(String, String)> = params.into_iter().collect();
650 for id in song_ids {
651 query.push(("songId".into(), id.clone()));
652 }
653
654 let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
655 let inner = resp.subsonic_response;
656 if inner.status != "ok" {
657 if let Some(err) = inner.error {
658 return Err(SubsonicError::Api {
659 code: err.code,
660 message: err.message,
661 });
662 }
663 return Err(SubsonicError::BadResponse);
664 }
665 Ok(inner.playlist)
669 }
670
671 pub fn update_playlist(
673 &self,
674 id: &str,
675 name: Option<&str>,
676 comment: Option<&str>,
677 public: Option<bool>,
678 ) -> Result<(), SubsonicError> {
679 let mut extra: Vec<(&str, String)> = vec![("playlistId", id.to_string())];
680 if let Some(name) = name {
681 extra.push(("name", name.to_string()));
682 }
683 if let Some(comment) = comment {
684 extra.push(("comment", comment.to_string()));
685 }
686 if let Some(public) = public {
687 extra.push(("public", public.to_string()));
688 }
689 let borrowed: Vec<(&str, &str)> = extra.iter().map(|(k, v)| (*k, v.as_str())).collect();
690 self.get_with_params("updatePlaylist", &borrowed)?;
691 Ok(())
692 }
693
694 pub fn delete_playlist(&self, id: &str) -> Result<(), SubsonicError> {
695 self.get_with_params("deletePlaylist", &[("id", id)])?;
696 Ok(())
697 }
698
699 pub fn koan_users(&self) -> Result<Vec<KoanUser>, SubsonicError> {
702 Ok(self
703 .get("koanUsers")?
704 .users
705 .map(|u| u.user)
706 .unwrap_or_default())
707 }
708
709 pub fn koan_create_user(
711 &self,
712 username: &str,
713 role: &str,
714 ) -> Result<KoanInvite, SubsonicError> {
715 self.get_with_params("koanCreateUser", &[("username", username), ("role", role)])?
716 .invite
717 .ok_or(SubsonicError::BadResponse)
718 }
719
720 pub fn koan_invite(&self, username: &str, reset: bool) -> Result<KoanInvite, SubsonicError> {
722 let reset = if reset { "true" } else { "false" };
723 self.get_with_params("koanInvite", &[("username", username), ("reset", reset)])?
724 .invite
725 .ok_or(SubsonicError::BadResponse)
726 }
727
728 pub fn koan_set_user_role(&self, username: &str, role: &str) -> Result<(), SubsonicError> {
729 self.get_with_params("koanSetUserRole", &[("username", username), ("role", role)])?;
730 Ok(())
731 }
732
733 pub fn koan_delete_user(&self, username: &str) -> Result<(), SubsonicError> {
734 self.get_with_params("koanDeleteUser", &[("username", username)])?;
735 Ok(())
736 }
737
738 pub fn koan_command(&self, to: &str, command: &str) -> Result<(), SubsonicError> {
741 self.get_with_params("koanCommand", &[("to", to), ("command", command)])?;
742 Ok(())
743 }
744
745 pub fn auth(&self) -> &SubsonicAuth {
746 &self.auth
747 }
748
749 pub fn base_url(&self) -> &str {
751 &self.auth.base_url
752 }
753}
754
755#[derive(Debug, Deserialize)]
758struct SubsonicResponseWrapper {
759 #[serde(rename = "subsonic-response")]
760 subsonic_response: SubsonicResponse,
761}
762
763#[derive(Debug, Deserialize)]
764#[serde(rename_all = "camelCase")]
765struct SubsonicResponse {
766 status: String,
767 #[serde(rename = "type")]
770 server_type: Option<String>,
771 server_version: Option<String>,
772 #[serde(default)]
773 open_subsonic: bool,
774 open_subsonic_extensions: Option<Vec<SubsonicExtension>>,
775 error: Option<SubsonicApiError>,
776 artists: Option<SubsonicArtists>,
777 album: Option<SubsonicAlbumFull>,
778 album_list2: Option<SubsonicAlbumList>,
779 search_result3: Option<SubsonicSearchResult>,
780 starred2: Option<SubsonicStarred>,
781 shares: Option<SubsonicShares>,
782 similar_songs2: Option<SubsonicSimilarSongs>,
783 playlists: Option<SubsonicPlaylists>,
784 playlist: Option<SubsonicPlaylistFull>,
785 scan_status: Option<SubsonicScanStatus>,
786 indexes: Option<SubsonicIndexes>,
787 users: Option<KoanUsers>,
788 invite: Option<KoanInvite>,
789}
790
791#[derive(Debug, Deserialize)]
792struct KoanUsers {
793 #[serde(default)]
794 user: Vec<KoanUser>,
795}
796
797#[derive(Debug, Clone, Deserialize)]
799pub struct KoanUser {
800 pub username: String,
801 pub role: String,
802}
803
804#[derive(Debug, Clone, Deserialize)]
807pub struct KoanInvite {
808 pub username: String,
809 pub password: String,
810}
811
812impl SubsonicResponse {
813 fn has_extension(&self, name: &str) -> bool {
814 self.open_subsonic_extensions
815 .iter()
816 .flatten()
817 .any(|ext| ext.name == name)
818 }
819}
820
821#[derive(Debug, Deserialize)]
822struct SubsonicExtension {
823 name: String,
824 #[serde(default)]
825 versions: Vec<i64>,
826}
827
828#[derive(Debug, Deserialize)]
829#[serde(rename_all = "camelCase")]
830struct SubsonicIndexes {
831 last_modified: Option<i64>,
832}
833
834#[derive(Debug, Deserialize)]
835struct SubsonicScanStatus {
836 count: Option<u64>,
837}
838
839#[derive(Debug, Deserialize)]
840struct SubsonicApiError {
841 code: i32,
842 message: String,
843}
844
845#[derive(Debug, Deserialize)]
846struct SubsonicArtists {
847 index: Vec<SubsonicArtistIndex>,
848}
849
850#[derive(Debug, Deserialize)]
851struct SubsonicArtistIndex {
852 artist: Vec<SubsonicArtist>,
853}
854
855#[derive(Debug, Clone, Deserialize)]
856#[serde(rename_all = "camelCase")]
857pub struct SubsonicArtist {
858 pub id: String,
859 pub name: String,
860 pub album_count: Option<i32>,
861 #[serde(default, deserialize_with = "non_empty")]
864 pub music_brainz_id: Option<String>,
865 #[serde(default, deserialize_with = "non_empty")]
866 pub sort_name: Option<String>,
867}
868
869#[derive(Debug, Clone, Deserialize)]
870#[serde(rename_all = "camelCase")]
871pub struct SubsonicAlbum {
872 pub id: String,
873 pub name: String,
874 pub artist: Option<String>,
875 pub artist_id: Option<String>,
876 pub song_count: Option<i32>,
877 #[serde(default)]
879 pub duration: Option<i64>,
880 pub year: Option<i32>,
881 #[serde(default, deserialize_with = "non_empty")]
882 pub genre: Option<String>,
883 pub created: Option<String>,
884 #[serde(default, deserialize_with = "non_empty")]
887 pub music_brainz_id: Option<String>,
888 #[serde(default, deserialize_with = "non_empty")]
889 pub sort_name: Option<String>,
890 #[serde(default)]
891 pub record_labels: Vec<SubsonicName>,
892}
893
894#[derive(Debug, Clone, Deserialize)]
897pub struct SubsonicName {
898 pub name: String,
899}
900
901#[derive(Debug, Clone, Deserialize)]
902#[serde(rename_all = "camelCase")]
903pub struct SubsonicAlbumFull {
904 pub id: String,
905 pub name: String,
906 pub artist: Option<String>,
907 pub artist_id: Option<String>,
908 pub year: Option<i32>,
909 #[serde(default, deserialize_with = "non_empty")]
910 pub genre: Option<String>,
911 pub song_count: Option<i32>,
912 pub created: Option<String>,
913 #[serde(default, deserialize_with = "non_empty")]
914 pub music_brainz_id: Option<String>,
915 #[serde(default, deserialize_with = "non_empty")]
916 pub sort_name: Option<String>,
917 #[serde(default)]
918 pub record_labels: Vec<SubsonicName>,
919 #[serde(default)]
920 pub song: Vec<SubsonicSong>,
921}
922
923#[derive(Debug, Clone, Deserialize)]
924#[serde(rename_all = "camelCase")]
925pub struct SubsonicSong {
926 pub id: String,
927 pub title: String,
928 pub album: Option<String>,
929 pub artist: Option<String>,
930 pub track: Option<i32>,
931 pub disc_number: Option<i32>,
932 pub year: Option<i32>,
933 #[serde(default, deserialize_with = "non_empty")]
934 pub genre: Option<String>,
935 pub duration: Option<i64>,
936 pub bit_rate: Option<i32>,
937 pub suffix: Option<String>,
938 pub content_type: Option<String>,
939 pub album_id: Option<String>,
940 pub artist_id: Option<String>,
941 pub sampling_rate: Option<i32>,
944 pub bit_depth: Option<i32>,
945 pub channel_count: Option<i32>,
946 #[serde(default, deserialize_with = "non_empty")]
947 pub music_brainz_id: Option<String>,
948}
949
950#[derive(Debug, Deserialize)]
951struct SubsonicAlbumList {
952 #[serde(default)]
953 album: Vec<SubsonicAlbum>,
954}
955
956#[derive(Debug, Default, Deserialize)]
957pub struct SubsonicSearchResult {
958 #[serde(default)]
959 pub artist: Vec<SubsonicArtist>,
960 #[serde(default)]
961 pub album: Vec<SubsonicAlbum>,
962 #[serde(default)]
963 pub song: Vec<SubsonicSong>,
964}
965
966#[derive(Debug, Default, Deserialize)]
967pub struct SubsonicStarred {
968 #[serde(default)]
969 pub song: Vec<SubsonicSong>,
970 #[serde(default)]
971 pub album: Vec<SubsonicAlbum>,
972 #[serde(default)]
973 pub artist: Vec<SubsonicArtist>,
974}
975
976#[derive(Debug, Deserialize)]
977pub struct SubsonicSimilarSongs {
978 pub song: Option<Vec<SubsonicSong>>,
979}
980
981#[derive(Debug, Default, Deserialize)]
982struct SubsonicPlaylists {
983 #[serde(default)]
984 playlist: Vec<SubsonicPlaylist>,
985}
986
987#[derive(Debug, Clone, Deserialize)]
989#[serde(rename_all = "camelCase")]
990pub struct SubsonicPlaylist {
991 pub id: String,
992 pub name: String,
993 pub comment: Option<String>,
994 pub owner: Option<String>,
995 #[serde(default)]
996 pub public: bool,
997 pub song_count: Option<i64>,
998 pub duration: Option<i64>,
999 pub created: Option<String>,
1000 pub changed: Option<String>,
1001}
1002
1003#[derive(Debug, Clone, Deserialize)]
1004#[serde(rename_all = "camelCase")]
1005pub struct SubsonicPlaylistFull {
1006 #[serde(flatten)]
1007 pub playlist: SubsonicPlaylist,
1008 #[serde(default)]
1009 pub entry: Vec<SubsonicSong>,
1010}
1011
1012#[derive(Debug, Deserialize)]
1013struct SubsonicShares {
1014 #[serde(default)]
1015 share: Vec<SubsonicShare>,
1016}
1017
1018#[derive(Debug, Clone, Deserialize)]
1019#[serde(rename_all = "camelCase")]
1020pub struct SubsonicShare {
1021 pub id: String,
1022 pub url: Option<String>,
1023 pub description: Option<String>,
1024 pub username: Option<String>,
1025 pub created: Option<String>,
1026 pub expires: Option<String>,
1027 pub visit_count: Option<i64>,
1028}
1029
1030fn non_empty<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
1037 Ok(Option::<String>::deserialize(d)?.filter(|s| !s.is_empty()))
1038}
1039
1040fn random_salt() -> Result<String, getrandom::Error> {
1047 let mut buf = [0u8; 12];
1048 getrandom::fill(&mut buf)?;
1049 Ok(buf.iter().map(|b| format!("{:02x}", b)).collect())
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054 use super::*;
1055
1056 fn response(json: &str) -> SubsonicResponse {
1057 serde_json::from_str::<SubsonicResponseWrapper>(json)
1058 .unwrap()
1059 .subsonic_response
1060 }
1061
1062 #[test]
1063 fn http_errors_leave_the_signed_url_out() {
1064 let e = reqwest::blocking::get("http://127.0.0.1:1/rest/stream?u=owner&p=enc:736563726574")
1066 .unwrap_err();
1067 assert!(e.to_string().contains("enc:736563726574"));
1068 let shown = SubsonicError::from(e).to_string();
1069 assert!(!shown.contains("enc:"), "{shown}");
1070 let e = reqwest::blocking::get("http://127.0.0.1:1/rest/stream?p=enc:736563726574")
1071 .unwrap_err();
1072 assert!(!DownloadError::from(e).to_string().contains("enc:"));
1073 }
1074
1075 #[test]
1076 fn playback_report_is_read_from_the_advertised_extensions() {
1077 let navidrome = response(
1078 r#"{"subsonic-response": {
1079 "status": "ok", "version": "1.16.1", "type": "navidrome",
1080 "openSubsonic": true,
1081 "openSubsonicExtensions": [
1082 {"name": "transcodeOffset", "versions": [1]},
1083 {"name": "playbackReport", "versions": [1]}
1084 ]
1085 }}"#,
1086 );
1087 assert!(navidrome.has_extension(PLAYBACK_REPORT_EXTENSION));
1088
1089 let without = response(
1090 r#"{"subsonic-response": {
1091 "status": "ok", "version": "1.16.1",
1092 "openSubsonicExtensions": [{"name": "songLyrics", "versions": [1, 2]}]
1093 }}"#,
1094 );
1095 assert!(!without.has_extension(PLAYBACK_REPORT_EXTENSION));
1096
1097 let plain = response(r#"{"subsonic-response": {"status": "ok", "version": "1.16.1"}}"#);
1098 assert!(!plain.has_extension(PLAYBACK_REPORT_EXTENSION));
1099 }
1100
1101 #[test]
1104 fn test_deserialize_subsonic_song() {
1105 let json = r#"{
1106 "id": "42",
1107 "title": "Space Oddity",
1108 "album": "Space Oddity",
1109 "artist": "David Bowie",
1110 "track": 1,
1111 "discNumber": 1,
1112 "year": 1969,
1113 "genre": "Rock",
1114 "duration": 314,
1115 "bitRate": 320,
1116 "suffix": "mp3",
1117 "contentType": "audio/mpeg",
1118 "albumId": "7",
1119 "artistId": "3"
1120 }"#;
1121
1122 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1123
1124 assert_eq!(song.id, "42");
1125 assert_eq!(song.title, "Space Oddity");
1126 assert_eq!(song.album.as_deref(), Some("Space Oddity"));
1127 assert_eq!(song.artist.as_deref(), Some("David Bowie"));
1128 assert_eq!(song.track, Some(1));
1129 assert_eq!(song.disc_number, Some(1));
1130 assert_eq!(song.year, Some(1969));
1131 assert_eq!(song.genre.as_deref(), Some("Rock"));
1132 assert_eq!(song.duration, Some(314));
1133 assert_eq!(song.bit_rate, Some(320));
1134 assert_eq!(song.suffix.as_deref(), Some("mp3"));
1135 assert_eq!(song.content_type.as_deref(), Some("audio/mpeg"));
1136 assert_eq!(song.album_id.as_deref(), Some("7"));
1137 assert_eq!(song.artist_id.as_deref(), Some("3"));
1138 }
1139
1140 #[test]
1144 fn opensubsonic_quality_fields_are_read() {
1145 let json = r#"{
1146 "id": "000XtGC7jsWEbOjDsZi4Xw",
1147 "title": "Anguish",
1148 "suffix": "flac",
1149 "bitRate": 913,
1150 "samplingRate": 44100,
1151 "bitDepth": 16,
1152 "channelCount": 2
1153 }"#;
1154
1155 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1156
1157 assert_eq!(song.sampling_rate, Some(44100));
1158 assert_eq!(song.bit_depth, Some(16));
1159 assert_eq!(song.channel_count, Some(2));
1160 }
1161
1162 #[test]
1165 fn a_plain_subsonic_song_has_no_quality_figures() {
1166 let json = r#"{"id": "1", "title": "Track", "bitRate": 320}"#;
1167 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1168
1169 assert_eq!(song.sampling_rate, None);
1170 assert_eq!(song.bit_depth, None);
1171 assert_eq!(song.channel_count, None);
1172 }
1173
1174 #[test]
1175 fn test_deserialize_subsonic_song_optional_fields_absent() {
1176 let json = r#"{"id": "99", "title": "Minimal Track"}"#;
1178
1179 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1180
1181 assert_eq!(song.id, "99");
1182 assert_eq!(song.title, "Minimal Track");
1183 assert!(song.album.is_none());
1184 assert!(song.artist.is_none());
1185 assert!(song.track.is_none());
1186 assert!(song.disc_number.is_none());
1187 assert!(song.year.is_none());
1188 assert!(song.duration.is_none());
1189 assert!(song.bit_rate.is_none());
1190 }
1191
1192 #[test]
1195 fn test_deserialize_album_list() {
1196 let json = r#"{
1197 "subsonic-response": {
1198 "status": "ok",
1199 "version": "1.16.1",
1200 "albumList2": {
1201 "album": [
1202 {
1203 "id": "1",
1204 "name": "Abbey Road",
1205 "artist": "The Beatles",
1206 "artistId": "10",
1207 "songCount": 17,
1208 "year": 1969,
1209 "genre": "Rock",
1210 "created": "2020-01-01T00:00:00"
1211 },
1212 {
1213 "id": "2",
1214 "name": "Led Zeppelin IV",
1215 "artist": "Led Zeppelin",
1216 "artistId": "11",
1217 "songCount": 8,
1218 "year": 1971,
1219 "genre": "Hard Rock",
1220 "created": "2020-01-02T00:00:00"
1221 }
1222 ]
1223 }
1224 }
1225 }"#;
1226
1227 let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
1228 let album_list = wrapper
1229 .subsonic_response
1230 .album_list2
1231 .expect("album_list2 should be present");
1232
1233 assert_eq!(album_list.album.len(), 2);
1234
1235 let first = &album_list.album[0];
1236 assert_eq!(first.id, "1");
1237 assert_eq!(first.name, "Abbey Road");
1238 assert_eq!(first.artist.as_deref(), Some("The Beatles"));
1239 assert_eq!(first.artist_id.as_deref(), Some("10"));
1240 assert_eq!(first.song_count, Some(17));
1241 assert_eq!(first.year, Some(1969));
1242
1243 let second = &album_list.album[1];
1244 assert_eq!(second.id, "2");
1245 assert_eq!(second.name, "Led Zeppelin IV");
1246 assert_eq!(second.song_count, Some(8));
1247 }
1248
1249 #[test]
1252 fn test_auth_params_format() {
1253 let client = SubsonicClient::new("http://localhost:4533", "alice", "secret");
1254 let params = client.auth_params().unwrap();
1255
1256 assert!(params.contains_key("u"), "missing 'u' param");
1258 assert!(params.contains_key("t"), "missing 't' param");
1259 assert!(params.contains_key("s"), "missing 's' param");
1260 assert!(params.contains_key("v"), "missing 'v' param");
1261 assert!(params.contains_key("c"), "missing 'c' param");
1262 assert!(params.contains_key("f"), "missing 'f' param");
1263 assert_eq!(params.len(), 6);
1264
1265 assert_eq!(params["u"], "alice");
1266 assert_eq!(params["v"], "1.16.1");
1267 assert_eq!(params["c"], "koan");
1268 assert_eq!(params["f"], "json");
1269 }
1270
1271 #[test]
1272 fn test_auth_params_over_https_send_the_hex_password() {
1273 let client = SubsonicClient::new("https://koan.example", "alice", "hi");
1274 let params = client.auth_params().unwrap();
1275 assert_eq!(params["p"], "enc:6869");
1276 assert!(!params.contains_key("t") && !params.contains_key("s"));
1277 }
1278
1279 #[test]
1280 fn test_auth_params_token_is_md5_of_password_plus_salt() {
1281 let client = SubsonicClient::new("http://localhost:4533", "bob", "letmein");
1282 let params = client.auth_params().unwrap();
1283
1284 let salt = ¶ms["s"];
1285 let token = ¶ms["t"];
1286
1287 let expected = format!("{:x}", md5::compute(format!("letmein{}", salt)));
1289 assert_eq!(token, &expected);
1290 }
1291
1292 #[test]
1293 fn test_auth_params_salt_is_different_each_call() {
1294 let client = SubsonicClient::new("http://localhost:4533", "user", "pass");
1295 let params1 = client.auth_params().unwrap();
1296 let params2 = client.auth_params().unwrap();
1297
1298 assert_ne!(params1["s"], params2["s"], "salt should be random per call");
1301 }
1302
1303 #[test]
1306 fn test_stream_url_has_auth() {
1307 let client = SubsonicClient::new("http://myserver:4533", "user", "pass");
1308 let url = client.stream_url("track-123").unwrap();
1309
1310 assert!(url.contains("track-123"), "url must include the track id");
1311 assert!(url.contains("u=user"), "url must include username param");
1312 assert!(url.contains("v=1.16.1"), "url must include api version");
1313 assert!(url.contains("c=koan"), "url must include client name");
1314 assert!(url.contains("f=json"), "url must include format param");
1315 assert!(url.contains("/rest/stream"), "url must target /rest/stream");
1316 assert!(
1317 url.starts_with("http://myserver:4533"),
1318 "url must use the configured base_url"
1319 );
1320 }
1321
1322 #[test]
1323 fn test_stream_url_base_url_trailing_slash_normalised() {
1324 let client_with_slash = SubsonicClient::new("http://myserver:4533/", "u", "p");
1326 let client_no_slash = SubsonicClient::new("http://myserver:4533", "u", "p");
1327
1328 let url_with = client_with_slash.stream_url("1").unwrap();
1329 let url_without = client_no_slash.stream_url("1").unwrap();
1330
1331 assert!(
1333 url_with.contains("/rest/stream"),
1334 "should not have double slash"
1335 );
1336 assert!(!url_with.contains("//rest"), "should not have double slash");
1337 assert_eq!(
1339 url_with.split('?').next(),
1340 url_without.split('?').next(),
1341 "path segment should be identical regardless of trailing slash"
1342 );
1343 }
1344
1345 #[test]
1348 fn test_deserialize_album_full_with_songs() {
1349 let json = r#"{
1350 "id": "5",
1351 "name": "Kind of Blue",
1352 "artist": "Miles Davis",
1353 "artistId": "20",
1354 "year": 1959,
1355 "genre": "Jazz",
1356 "songCount": 5,
1357 "created": "2021-06-01T00:00:00",
1358 "song": [
1359 {"id": "101", "title": "So What"},
1360 {"id": "102", "title": "Freddie Freeloader"},
1361 {"id": "103", "title": "Blue in Green"}
1362 ]
1363 }"#;
1364
1365 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1366
1367 assert_eq!(album.id, "5");
1368 assert_eq!(album.name, "Kind of Blue");
1369 assert_eq!(album.artist.as_deref(), Some("Miles Davis"));
1370 assert_eq!(album.year, Some(1959));
1371 assert_eq!(album.song.len(), 3);
1372 assert_eq!(album.song[0].title, "So What");
1373 assert_eq!(album.song[2].id, "103");
1374 }
1375
1376 #[test]
1377 fn test_deserialize_album_full_empty_song_list() {
1378 let json = r#"{"id": "9", "name": "No Tracks Yet"}"#;
1380
1381 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1382
1383 assert_eq!(album.id, "9");
1384 assert!(album.song.is_empty(), "song list should default to empty");
1385 }
1386
1387 #[test]
1390 fn test_deserialize_search_result_mixed() {
1391 let json = r#"{
1392 "artist": [{"id": "1", "name": "Artist One"}],
1393 "album": [{"id": "2", "name": "Album One"}],
1394 "song": [{"id": "3", "title": "Song One"}]
1395 }"#;
1396
1397 let result: SubsonicSearchResult = serde_json::from_str(json).unwrap();
1398
1399 assert_eq!(result.artist.len(), 1);
1400 assert_eq!(result.artist[0].name, "Artist One");
1401 assert_eq!(result.album.len(), 1);
1402 assert_eq!(result.album[0].name, "Album One");
1403 assert_eq!(result.song.len(), 1);
1404 assert_eq!(result.song[0].title, "Song One");
1405 }
1406
1407 #[test]
1408 fn empty_opensubsonic_ids_are_absent() {
1409 let json = r#"{"id": "1", "title": "T", "musicBrainzId": ""}"#;
1410 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1411 assert_eq!(song.music_brainz_id, None);
1412
1413 let json = r#"{"id": "2", "name": "A", "musicBrainzId": "", "sortName": ""}"#;
1414 let album: SubsonicAlbum = serde_json::from_str(json).unwrap();
1415 assert_eq!((album.music_brainz_id, album.sort_name), (None, None));
1416
1417 let json = r#"{"id": "3", "name": "A", "musicBrainzId": "mb-1"}"#;
1418 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1419 assert_eq!(album.music_brainz_id.as_deref(), Some("mb-1"));
1420 assert_eq!(album.sort_name, None);
1421 }
1422
1423 #[test]
1424 fn test_deserialize_scan_status_count() {
1425 let json = r#"{"subsonic-response":{"status":"ok","scanStatus":{"scanning":false,"count":49700}}}"#;
1426 let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
1427 let status = wrapper.subsonic_response.scan_status.unwrap();
1428 assert_eq!(status.count, Some(49_700));
1429 }
1430
1431 #[test]
1432 fn test_deserialize_search_result_defaults_to_empty() {
1433 let result: SubsonicSearchResult = serde_json::from_str("{}").unwrap();
1435
1436 assert!(result.artist.is_empty());
1437 assert!(result.album.is_empty());
1438 assert!(result.song.is_empty());
1439 }
1440}