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 search(&self, query: &str) -> Result<SubsonicSearchResult, SubsonicError> {
424 let resp = self.get_with_params("search3", &[("query", query)])?;
425 Ok(resp.search_result3.unwrap_or_default())
426 }
427
428 pub fn scrobble(&self, track_id: &str, heard_at_ms: u64) -> Result<(), SubsonicError> {
431 let at = heard_at_ms.to_string();
432 self.get_with_params(
433 "scrobble",
434 &[("id", track_id), ("submission", "true"), ("time", &at)],
435 )?;
436 Ok(())
437 }
438
439 pub fn report_playback(
448 &self,
449 track_id: &str,
450 state: PlaybackReportState,
451 position_ms: u64,
452 ) -> Result<(), SubsonicError> {
453 if self.supports_playback_report() {
454 let position = position_ms.to_string();
455 self.get_with_params(
456 "reportPlayback",
457 &[
458 ("mediaId", track_id),
459 ("mediaType", "song"),
460 ("positionMs", &position),
461 ("state", state.as_str()),
462 ("ignoreScrobble", "true"),
463 ],
464 )?;
465 return Ok(());
466 }
467 if state == PlaybackReportState::Playing {
468 let position = (position_ms / 1000).to_string();
469 self.get_with_params(
470 "scrobble",
471 &[
472 ("id", track_id),
473 ("submission", "false"),
474 ("position", &position),
475 ],
476 )?;
477 }
478 Ok(())
479 }
480
481 fn supports_playback_report(&self) -> bool {
485 if let Some(&known) = self.playback_report.get() {
486 return known;
487 }
488 let supported = match self.get("getOpenSubsonicExtensions") {
489 Ok(resp) => resp.has_extension(PLAYBACK_REPORT_EXTENSION),
490 Err(SubsonicError::Http(e)) if e.is_connect() || e.is_timeout() => return false,
491 Err(_) => false,
492 };
493 *self.playback_report.get_or_init(|| supported)
494 }
495
496 pub fn star(&self, track_id: &str) -> Result<(), SubsonicError> {
498 self.get_with_params("star", &[("id", track_id)])?;
499 Ok(())
500 }
501
502 pub fn unstar(&self, track_id: &str) -> Result<(), SubsonicError> {
504 self.get_with_params("unstar", &[("id", track_id)])?;
505 Ok(())
506 }
507
508 pub fn get_starred_all(&self) -> Result<SubsonicStarred, SubsonicError> {
513 let resp = self.get("getStarred2")?;
514 Ok(resp.starred2.unwrap_or_default())
515 }
516
517 pub fn star_album(&self, album_id: &str) -> Result<(), SubsonicError> {
520 self.get_with_params("star", &[("albumId", album_id)])?;
521 Ok(())
522 }
523
524 pub fn unstar_album(&self, album_id: &str) -> Result<(), SubsonicError> {
525 self.get_with_params("unstar", &[("albumId", album_id)])?;
526 Ok(())
527 }
528
529 pub fn star_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
530 self.get_with_params("star", &[("artistId", artist_id)])?;
531 Ok(())
532 }
533
534 pub fn unstar_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
535 self.get_with_params("unstar", &[("artistId", artist_id)])?;
536 Ok(())
537 }
538
539 pub fn create_share(
542 &self,
543 ids: &[&str],
544 description: Option<&str>,
545 ) -> Result<SubsonicShare, SubsonicError> {
546 let url = format!("{}/rest/createShare", self.auth.base_url);
547 let mut params = self.auth_params()?;
548 if let Some(desc) = description {
549 params.insert("description".into(), desc.to_string());
550 }
551
552 let mut query: Vec<(String, String)> = params.into_iter().collect();
554 for id in ids {
555 query.push(("id".into(), (*id).to_string()));
556 }
557
558 let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
559
560 let inner = resp.subsonic_response;
561 if inner.status != "ok" {
562 if let Some(err) = inner.error {
563 return Err(SubsonicError::Api {
564 code: err.code,
565 message: err.message,
566 });
567 }
568 return Err(SubsonicError::BadResponse);
569 }
570
571 inner
572 .shares
573 .and_then(|s| s.share.into_iter().next())
574 .ok_or(SubsonicError::BadResponse)
575 }
576
577 pub fn get_similar_songs(
580 &self,
581 song_id: &str,
582 count: usize,
583 ) -> Result<Vec<SubsonicSong>, SubsonicError> {
584 let count_str = count.to_string();
585 let resp = self.get_with_params(
586 "getSimilarSongs2",
587 &[("id", song_id), ("count", &count_str)],
588 )?;
589 Ok(resp.similar_songs2.and_then(|s| s.song).unwrap_or_default())
590 }
591
592 pub fn get_playlists(&self) -> Result<Vec<SubsonicPlaylist>, SubsonicError> {
596 let resp = self.get("getPlaylists")?;
597 Ok(resp.playlists.map(|p| p.playlist).unwrap_or_default())
598 }
599
600 pub fn get_playlist(&self, id: &str) -> Result<SubsonicPlaylistFull, SubsonicError> {
602 let resp = self.get_with_params("getPlaylist", &[("id", id)])?;
603 resp.playlist.ok_or(SubsonicError::BadResponse)
604 }
605
606 pub fn create_playlist(
615 &self,
616 playlist_id: Option<&str>,
617 name: &str,
618 song_ids: &[String],
619 ) -> Result<Option<SubsonicPlaylistFull>, SubsonicError> {
620 let url = format!("{}/rest/createPlaylist", self.auth.base_url);
621 let mut params = self.auth_params()?;
622 match playlist_id {
623 Some(id) => {
624 params.insert("playlistId".into(), id.to_string());
625 params.insert("name".into(), name.to_string());
628 }
629 None => {
630 params.insert("name".into(), name.to_string());
631 }
632 }
633
634 let mut query: Vec<(String, String)> = params.into_iter().collect();
636 for id in song_ids {
637 query.push(("songId".into(), id.clone()));
638 }
639
640 let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
641 let inner = resp.subsonic_response;
642 if inner.status != "ok" {
643 if let Some(err) = inner.error {
644 return Err(SubsonicError::Api {
645 code: err.code,
646 message: err.message,
647 });
648 }
649 return Err(SubsonicError::BadResponse);
650 }
651 Ok(inner.playlist)
655 }
656
657 pub fn update_playlist(
659 &self,
660 id: &str,
661 name: Option<&str>,
662 comment: Option<&str>,
663 public: Option<bool>,
664 ) -> Result<(), SubsonicError> {
665 let mut extra: Vec<(&str, String)> = vec![("playlistId", id.to_string())];
666 if let Some(name) = name {
667 extra.push(("name", name.to_string()));
668 }
669 if let Some(comment) = comment {
670 extra.push(("comment", comment.to_string()));
671 }
672 if let Some(public) = public {
673 extra.push(("public", public.to_string()));
674 }
675 let borrowed: Vec<(&str, &str)> = extra.iter().map(|(k, v)| (*k, v.as_str())).collect();
676 self.get_with_params("updatePlaylist", &borrowed)?;
677 Ok(())
678 }
679
680 pub fn delete_playlist(&self, id: &str) -> Result<(), SubsonicError> {
681 self.get_with_params("deletePlaylist", &[("id", id)])?;
682 Ok(())
683 }
684
685 pub fn koan_users(&self) -> Result<Vec<KoanUser>, SubsonicError> {
688 Ok(self
689 .get("koanUsers")?
690 .users
691 .map(|u| u.user)
692 .unwrap_or_default())
693 }
694
695 pub fn koan_create_user(
697 &self,
698 username: &str,
699 role: &str,
700 ) -> Result<KoanInvite, SubsonicError> {
701 self.get_with_params("koanCreateUser", &[("username", username), ("role", role)])?
702 .invite
703 .ok_or(SubsonicError::BadResponse)
704 }
705
706 pub fn koan_invite(&self, username: &str, reset: bool) -> Result<KoanInvite, SubsonicError> {
708 let reset = if reset { "true" } else { "false" };
709 self.get_with_params("koanInvite", &[("username", username), ("reset", reset)])?
710 .invite
711 .ok_or(SubsonicError::BadResponse)
712 }
713
714 pub fn koan_set_user_role(&self, username: &str, role: &str) -> Result<(), SubsonicError> {
715 self.get_with_params("koanSetUserRole", &[("username", username), ("role", role)])?;
716 Ok(())
717 }
718
719 pub fn koan_delete_user(&self, username: &str) -> Result<(), SubsonicError> {
720 self.get_with_params("koanDeleteUser", &[("username", username)])?;
721 Ok(())
722 }
723
724 pub fn koan_command(&self, to: &str, command: &str) -> Result<(), SubsonicError> {
727 self.get_with_params("koanCommand", &[("to", to), ("command", command)])?;
728 Ok(())
729 }
730
731 pub fn auth(&self) -> &SubsonicAuth {
732 &self.auth
733 }
734
735 pub fn base_url(&self) -> &str {
737 &self.auth.base_url
738 }
739}
740
741#[derive(Debug, Deserialize)]
744struct SubsonicResponseWrapper {
745 #[serde(rename = "subsonic-response")]
746 subsonic_response: SubsonicResponse,
747}
748
749#[derive(Debug, Deserialize)]
750#[serde(rename_all = "camelCase")]
751struct SubsonicResponse {
752 status: String,
753 #[serde(rename = "type")]
756 server_type: Option<String>,
757 server_version: Option<String>,
758 #[serde(default)]
759 open_subsonic: bool,
760 open_subsonic_extensions: Option<Vec<SubsonicExtension>>,
761 error: Option<SubsonicApiError>,
762 artists: Option<SubsonicArtists>,
763 album: Option<SubsonicAlbumFull>,
764 album_list2: Option<SubsonicAlbumList>,
765 search_result3: Option<SubsonicSearchResult>,
766 starred2: Option<SubsonicStarred>,
767 shares: Option<SubsonicShares>,
768 similar_songs2: Option<SubsonicSimilarSongs>,
769 playlists: Option<SubsonicPlaylists>,
770 playlist: Option<SubsonicPlaylistFull>,
771 scan_status: Option<SubsonicScanStatus>,
772 users: Option<KoanUsers>,
773 invite: Option<KoanInvite>,
774}
775
776#[derive(Debug, Deserialize)]
777struct KoanUsers {
778 #[serde(default)]
779 user: Vec<KoanUser>,
780}
781
782#[derive(Debug, Clone, Deserialize)]
784pub struct KoanUser {
785 pub username: String,
786 pub role: String,
787}
788
789#[derive(Debug, Clone, Deserialize)]
792pub struct KoanInvite {
793 pub username: String,
794 pub password: String,
795}
796
797impl SubsonicResponse {
798 fn has_extension(&self, name: &str) -> bool {
799 self.open_subsonic_extensions
800 .iter()
801 .flatten()
802 .any(|ext| ext.name == name)
803 }
804}
805
806#[derive(Debug, Deserialize)]
807struct SubsonicExtension {
808 name: String,
809 #[serde(default)]
810 versions: Vec<i64>,
811}
812
813#[derive(Debug, Deserialize)]
814struct SubsonicScanStatus {
815 count: Option<u64>,
816}
817
818#[derive(Debug, Deserialize)]
819struct SubsonicApiError {
820 code: i32,
821 message: String,
822}
823
824#[derive(Debug, Deserialize)]
825struct SubsonicArtists {
826 index: Vec<SubsonicArtistIndex>,
827}
828
829#[derive(Debug, Deserialize)]
830struct SubsonicArtistIndex {
831 artist: Vec<SubsonicArtist>,
832}
833
834#[derive(Debug, Clone, Deserialize)]
835#[serde(rename_all = "camelCase")]
836pub struct SubsonicArtist {
837 pub id: String,
838 pub name: String,
839 pub album_count: Option<i32>,
840 #[serde(default, deserialize_with = "non_empty")]
843 pub music_brainz_id: Option<String>,
844 #[serde(default, deserialize_with = "non_empty")]
845 pub sort_name: Option<String>,
846}
847
848#[derive(Debug, Clone, Deserialize)]
849#[serde(rename_all = "camelCase")]
850pub struct SubsonicAlbum {
851 pub id: String,
852 pub name: String,
853 pub artist: Option<String>,
854 pub artist_id: Option<String>,
855 pub song_count: Option<i32>,
856 #[serde(default)]
858 pub duration: Option<i64>,
859 pub year: Option<i32>,
860 #[serde(default, deserialize_with = "non_empty")]
861 pub genre: Option<String>,
862 pub created: Option<String>,
863 #[serde(default, deserialize_with = "non_empty")]
866 pub music_brainz_id: Option<String>,
867 #[serde(default, deserialize_with = "non_empty")]
868 pub sort_name: Option<String>,
869 #[serde(default)]
870 pub record_labels: Vec<SubsonicName>,
871}
872
873#[derive(Debug, Clone, Deserialize)]
876pub struct SubsonicName {
877 pub name: String,
878}
879
880#[derive(Debug, Clone, Deserialize)]
881#[serde(rename_all = "camelCase")]
882pub struct SubsonicAlbumFull {
883 pub id: String,
884 pub name: String,
885 pub artist: Option<String>,
886 pub artist_id: Option<String>,
887 pub year: Option<i32>,
888 #[serde(default, deserialize_with = "non_empty")]
889 pub genre: Option<String>,
890 pub song_count: Option<i32>,
891 pub created: Option<String>,
892 #[serde(default, deserialize_with = "non_empty")]
893 pub music_brainz_id: Option<String>,
894 #[serde(default, deserialize_with = "non_empty")]
895 pub sort_name: Option<String>,
896 #[serde(default)]
897 pub record_labels: Vec<SubsonicName>,
898 #[serde(default)]
899 pub song: Vec<SubsonicSong>,
900}
901
902#[derive(Debug, Clone, Deserialize)]
903#[serde(rename_all = "camelCase")]
904pub struct SubsonicSong {
905 pub id: String,
906 pub title: String,
907 pub album: Option<String>,
908 pub artist: Option<String>,
909 pub track: Option<i32>,
910 pub disc_number: Option<i32>,
911 pub year: Option<i32>,
912 #[serde(default, deserialize_with = "non_empty")]
913 pub genre: Option<String>,
914 pub duration: Option<i64>,
915 pub bit_rate: Option<i32>,
916 pub suffix: Option<String>,
917 pub content_type: Option<String>,
918 pub album_id: Option<String>,
919 pub artist_id: Option<String>,
920 pub sampling_rate: Option<i32>,
923 pub bit_depth: Option<i32>,
924 pub channel_count: Option<i32>,
925 #[serde(default, deserialize_with = "non_empty")]
926 pub music_brainz_id: Option<String>,
927}
928
929#[derive(Debug, Deserialize)]
930struct SubsonicAlbumList {
931 #[serde(default)]
932 album: Vec<SubsonicAlbum>,
933}
934
935#[derive(Debug, Default, Deserialize)]
936pub struct SubsonicSearchResult {
937 #[serde(default)]
938 pub artist: Vec<SubsonicArtist>,
939 #[serde(default)]
940 pub album: Vec<SubsonicAlbum>,
941 #[serde(default)]
942 pub song: Vec<SubsonicSong>,
943}
944
945#[derive(Debug, Default, Deserialize)]
946pub struct SubsonicStarred {
947 #[serde(default)]
948 pub song: Vec<SubsonicSong>,
949 #[serde(default)]
950 pub album: Vec<SubsonicAlbum>,
951 #[serde(default)]
952 pub artist: Vec<SubsonicArtist>,
953}
954
955#[derive(Debug, Deserialize)]
956pub struct SubsonicSimilarSongs {
957 pub song: Option<Vec<SubsonicSong>>,
958}
959
960#[derive(Debug, Default, Deserialize)]
961struct SubsonicPlaylists {
962 #[serde(default)]
963 playlist: Vec<SubsonicPlaylist>,
964}
965
966#[derive(Debug, Clone, Deserialize)]
968#[serde(rename_all = "camelCase")]
969pub struct SubsonicPlaylist {
970 pub id: String,
971 pub name: String,
972 pub comment: Option<String>,
973 pub owner: Option<String>,
974 #[serde(default)]
975 pub public: bool,
976 pub song_count: Option<i64>,
977 pub duration: Option<i64>,
978 pub created: Option<String>,
979 pub changed: Option<String>,
980}
981
982#[derive(Debug, Clone, Deserialize)]
983#[serde(rename_all = "camelCase")]
984pub struct SubsonicPlaylistFull {
985 #[serde(flatten)]
986 pub playlist: SubsonicPlaylist,
987 #[serde(default)]
988 pub entry: Vec<SubsonicSong>,
989}
990
991#[derive(Debug, Deserialize)]
992struct SubsonicShares {
993 #[serde(default)]
994 share: Vec<SubsonicShare>,
995}
996
997#[derive(Debug, Clone, Deserialize)]
998#[serde(rename_all = "camelCase")]
999pub struct SubsonicShare {
1000 pub id: String,
1001 pub url: Option<String>,
1002 pub description: Option<String>,
1003 pub username: Option<String>,
1004 pub created: Option<String>,
1005 pub expires: Option<String>,
1006 pub visit_count: Option<i64>,
1007}
1008
1009fn non_empty<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
1016 Ok(Option::<String>::deserialize(d)?.filter(|s| !s.is_empty()))
1017}
1018
1019fn random_salt() -> Result<String, getrandom::Error> {
1026 let mut buf = [0u8; 12];
1027 getrandom::fill(&mut buf)?;
1028 Ok(buf.iter().map(|b| format!("{:02x}", b)).collect())
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033 use super::*;
1034
1035 fn response(json: &str) -> SubsonicResponse {
1036 serde_json::from_str::<SubsonicResponseWrapper>(json)
1037 .unwrap()
1038 .subsonic_response
1039 }
1040
1041 #[test]
1042 fn http_errors_leave_the_signed_url_out() {
1043 let e = reqwest::blocking::get("http://127.0.0.1:1/rest/stream?u=owner&p=enc:736563726574")
1045 .unwrap_err();
1046 assert!(e.to_string().contains("enc:736563726574"));
1047 let shown = SubsonicError::from(e).to_string();
1048 assert!(!shown.contains("enc:"), "{shown}");
1049 let e = reqwest::blocking::get("http://127.0.0.1:1/rest/stream?p=enc:736563726574")
1050 .unwrap_err();
1051 assert!(!DownloadError::from(e).to_string().contains("enc:"));
1052 }
1053
1054 #[test]
1055 fn playback_report_is_read_from_the_advertised_extensions() {
1056 let navidrome = response(
1057 r#"{"subsonic-response": {
1058 "status": "ok", "version": "1.16.1", "type": "navidrome",
1059 "openSubsonic": true,
1060 "openSubsonicExtensions": [
1061 {"name": "transcodeOffset", "versions": [1]},
1062 {"name": "playbackReport", "versions": [1]}
1063 ]
1064 }}"#,
1065 );
1066 assert!(navidrome.has_extension(PLAYBACK_REPORT_EXTENSION));
1067
1068 let without = response(
1069 r#"{"subsonic-response": {
1070 "status": "ok", "version": "1.16.1",
1071 "openSubsonicExtensions": [{"name": "songLyrics", "versions": [1, 2]}]
1072 }}"#,
1073 );
1074 assert!(!without.has_extension(PLAYBACK_REPORT_EXTENSION));
1075
1076 let plain = response(r#"{"subsonic-response": {"status": "ok", "version": "1.16.1"}}"#);
1077 assert!(!plain.has_extension(PLAYBACK_REPORT_EXTENSION));
1078 }
1079
1080 #[test]
1083 fn test_deserialize_subsonic_song() {
1084 let json = r#"{
1085 "id": "42",
1086 "title": "Space Oddity",
1087 "album": "Space Oddity",
1088 "artist": "David Bowie",
1089 "track": 1,
1090 "discNumber": 1,
1091 "year": 1969,
1092 "genre": "Rock",
1093 "duration": 314,
1094 "bitRate": 320,
1095 "suffix": "mp3",
1096 "contentType": "audio/mpeg",
1097 "albumId": "7",
1098 "artistId": "3"
1099 }"#;
1100
1101 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1102
1103 assert_eq!(song.id, "42");
1104 assert_eq!(song.title, "Space Oddity");
1105 assert_eq!(song.album.as_deref(), Some("Space Oddity"));
1106 assert_eq!(song.artist.as_deref(), Some("David Bowie"));
1107 assert_eq!(song.track, Some(1));
1108 assert_eq!(song.disc_number, Some(1));
1109 assert_eq!(song.year, Some(1969));
1110 assert_eq!(song.genre.as_deref(), Some("Rock"));
1111 assert_eq!(song.duration, Some(314));
1112 assert_eq!(song.bit_rate, Some(320));
1113 assert_eq!(song.suffix.as_deref(), Some("mp3"));
1114 assert_eq!(song.content_type.as_deref(), Some("audio/mpeg"));
1115 assert_eq!(song.album_id.as_deref(), Some("7"));
1116 assert_eq!(song.artist_id.as_deref(), Some("3"));
1117 }
1118
1119 #[test]
1123 fn opensubsonic_quality_fields_are_read() {
1124 let json = r#"{
1125 "id": "000XtGC7jsWEbOjDsZi4Xw",
1126 "title": "Anguish",
1127 "suffix": "flac",
1128 "bitRate": 913,
1129 "samplingRate": 44100,
1130 "bitDepth": 16,
1131 "channelCount": 2
1132 }"#;
1133
1134 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1135
1136 assert_eq!(song.sampling_rate, Some(44100));
1137 assert_eq!(song.bit_depth, Some(16));
1138 assert_eq!(song.channel_count, Some(2));
1139 }
1140
1141 #[test]
1144 fn a_plain_subsonic_song_has_no_quality_figures() {
1145 let json = r#"{"id": "1", "title": "Track", "bitRate": 320}"#;
1146 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1147
1148 assert_eq!(song.sampling_rate, None);
1149 assert_eq!(song.bit_depth, None);
1150 assert_eq!(song.channel_count, None);
1151 }
1152
1153 #[test]
1154 fn test_deserialize_subsonic_song_optional_fields_absent() {
1155 let json = r#"{"id": "99", "title": "Minimal Track"}"#;
1157
1158 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1159
1160 assert_eq!(song.id, "99");
1161 assert_eq!(song.title, "Minimal Track");
1162 assert!(song.album.is_none());
1163 assert!(song.artist.is_none());
1164 assert!(song.track.is_none());
1165 assert!(song.disc_number.is_none());
1166 assert!(song.year.is_none());
1167 assert!(song.duration.is_none());
1168 assert!(song.bit_rate.is_none());
1169 }
1170
1171 #[test]
1174 fn test_deserialize_album_list() {
1175 let json = r#"{
1176 "subsonic-response": {
1177 "status": "ok",
1178 "version": "1.16.1",
1179 "albumList2": {
1180 "album": [
1181 {
1182 "id": "1",
1183 "name": "Abbey Road",
1184 "artist": "The Beatles",
1185 "artistId": "10",
1186 "songCount": 17,
1187 "year": 1969,
1188 "genre": "Rock",
1189 "created": "2020-01-01T00:00:00"
1190 },
1191 {
1192 "id": "2",
1193 "name": "Led Zeppelin IV",
1194 "artist": "Led Zeppelin",
1195 "artistId": "11",
1196 "songCount": 8,
1197 "year": 1971,
1198 "genre": "Hard Rock",
1199 "created": "2020-01-02T00:00:00"
1200 }
1201 ]
1202 }
1203 }
1204 }"#;
1205
1206 let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
1207 let album_list = wrapper
1208 .subsonic_response
1209 .album_list2
1210 .expect("album_list2 should be present");
1211
1212 assert_eq!(album_list.album.len(), 2);
1213
1214 let first = &album_list.album[0];
1215 assert_eq!(first.id, "1");
1216 assert_eq!(first.name, "Abbey Road");
1217 assert_eq!(first.artist.as_deref(), Some("The Beatles"));
1218 assert_eq!(first.artist_id.as_deref(), Some("10"));
1219 assert_eq!(first.song_count, Some(17));
1220 assert_eq!(first.year, Some(1969));
1221
1222 let second = &album_list.album[1];
1223 assert_eq!(second.id, "2");
1224 assert_eq!(second.name, "Led Zeppelin IV");
1225 assert_eq!(second.song_count, Some(8));
1226 }
1227
1228 #[test]
1231 fn test_auth_params_format() {
1232 let client = SubsonicClient::new("http://localhost:4533", "alice", "secret");
1233 let params = client.auth_params().unwrap();
1234
1235 assert!(params.contains_key("u"), "missing 'u' param");
1237 assert!(params.contains_key("t"), "missing 't' param");
1238 assert!(params.contains_key("s"), "missing 's' param");
1239 assert!(params.contains_key("v"), "missing 'v' param");
1240 assert!(params.contains_key("c"), "missing 'c' param");
1241 assert!(params.contains_key("f"), "missing 'f' param");
1242 assert_eq!(params.len(), 6);
1243
1244 assert_eq!(params["u"], "alice");
1245 assert_eq!(params["v"], "1.16.1");
1246 assert_eq!(params["c"], "koan");
1247 assert_eq!(params["f"], "json");
1248 }
1249
1250 #[test]
1251 fn test_auth_params_over_https_send_the_hex_password() {
1252 let client = SubsonicClient::new("https://koan.example", "alice", "hi");
1253 let params = client.auth_params().unwrap();
1254 assert_eq!(params["p"], "enc:6869");
1255 assert!(!params.contains_key("t") && !params.contains_key("s"));
1256 }
1257
1258 #[test]
1259 fn test_auth_params_token_is_md5_of_password_plus_salt() {
1260 let client = SubsonicClient::new("http://localhost:4533", "bob", "letmein");
1261 let params = client.auth_params().unwrap();
1262
1263 let salt = ¶ms["s"];
1264 let token = ¶ms["t"];
1265
1266 let expected = format!("{:x}", md5::compute(format!("letmein{}", salt)));
1268 assert_eq!(token, &expected);
1269 }
1270
1271 #[test]
1272 fn test_auth_params_salt_is_different_each_call() {
1273 let client = SubsonicClient::new("http://localhost:4533", "user", "pass");
1274 let params1 = client.auth_params().unwrap();
1275 let params2 = client.auth_params().unwrap();
1276
1277 assert_ne!(params1["s"], params2["s"], "salt should be random per call");
1280 }
1281
1282 #[test]
1285 fn test_stream_url_has_auth() {
1286 let client = SubsonicClient::new("http://myserver:4533", "user", "pass");
1287 let url = client.stream_url("track-123").unwrap();
1288
1289 assert!(url.contains("track-123"), "url must include the track id");
1290 assert!(url.contains("u=user"), "url must include username param");
1291 assert!(url.contains("v=1.16.1"), "url must include api version");
1292 assert!(url.contains("c=koan"), "url must include client name");
1293 assert!(url.contains("f=json"), "url must include format param");
1294 assert!(url.contains("/rest/stream"), "url must target /rest/stream");
1295 assert!(
1296 url.starts_with("http://myserver:4533"),
1297 "url must use the configured base_url"
1298 );
1299 }
1300
1301 #[test]
1302 fn test_stream_url_base_url_trailing_slash_normalised() {
1303 let client_with_slash = SubsonicClient::new("http://myserver:4533/", "u", "p");
1305 let client_no_slash = SubsonicClient::new("http://myserver:4533", "u", "p");
1306
1307 let url_with = client_with_slash.stream_url("1").unwrap();
1308 let url_without = client_no_slash.stream_url("1").unwrap();
1309
1310 assert!(
1312 url_with.contains("/rest/stream"),
1313 "should not have double slash"
1314 );
1315 assert!(!url_with.contains("//rest"), "should not have double slash");
1316 assert_eq!(
1318 url_with.split('?').next(),
1319 url_without.split('?').next(),
1320 "path segment should be identical regardless of trailing slash"
1321 );
1322 }
1323
1324 #[test]
1327 fn test_deserialize_album_full_with_songs() {
1328 let json = r#"{
1329 "id": "5",
1330 "name": "Kind of Blue",
1331 "artist": "Miles Davis",
1332 "artistId": "20",
1333 "year": 1959,
1334 "genre": "Jazz",
1335 "songCount": 5,
1336 "created": "2021-06-01T00:00:00",
1337 "song": [
1338 {"id": "101", "title": "So What"},
1339 {"id": "102", "title": "Freddie Freeloader"},
1340 {"id": "103", "title": "Blue in Green"}
1341 ]
1342 }"#;
1343
1344 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1345
1346 assert_eq!(album.id, "5");
1347 assert_eq!(album.name, "Kind of Blue");
1348 assert_eq!(album.artist.as_deref(), Some("Miles Davis"));
1349 assert_eq!(album.year, Some(1959));
1350 assert_eq!(album.song.len(), 3);
1351 assert_eq!(album.song[0].title, "So What");
1352 assert_eq!(album.song[2].id, "103");
1353 }
1354
1355 #[test]
1356 fn test_deserialize_album_full_empty_song_list() {
1357 let json = r#"{"id": "9", "name": "No Tracks Yet"}"#;
1359
1360 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1361
1362 assert_eq!(album.id, "9");
1363 assert!(album.song.is_empty(), "song list should default to empty");
1364 }
1365
1366 #[test]
1369 fn test_deserialize_search_result_mixed() {
1370 let json = r#"{
1371 "artist": [{"id": "1", "name": "Artist One"}],
1372 "album": [{"id": "2", "name": "Album One"}],
1373 "song": [{"id": "3", "title": "Song One"}]
1374 }"#;
1375
1376 let result: SubsonicSearchResult = serde_json::from_str(json).unwrap();
1377
1378 assert_eq!(result.artist.len(), 1);
1379 assert_eq!(result.artist[0].name, "Artist One");
1380 assert_eq!(result.album.len(), 1);
1381 assert_eq!(result.album[0].name, "Album One");
1382 assert_eq!(result.song.len(), 1);
1383 assert_eq!(result.song[0].title, "Song One");
1384 }
1385
1386 #[test]
1387 fn empty_opensubsonic_ids_are_absent() {
1388 let json = r#"{"id": "1", "title": "T", "musicBrainzId": ""}"#;
1389 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1390 assert_eq!(song.music_brainz_id, None);
1391
1392 let json = r#"{"id": "2", "name": "A", "musicBrainzId": "", "sortName": ""}"#;
1393 let album: SubsonicAlbum = serde_json::from_str(json).unwrap();
1394 assert_eq!((album.music_brainz_id, album.sort_name), (None, None));
1395
1396 let json = r#"{"id": "3", "name": "A", "musicBrainzId": "mb-1"}"#;
1397 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1398 assert_eq!(album.music_brainz_id.as_deref(), Some("mb-1"));
1399 assert_eq!(album.sort_name, None);
1400 }
1401
1402 #[test]
1403 fn test_deserialize_scan_status_count() {
1404 let json = r#"{"subsonic-response":{"status":"ok","scanStatus":{"scanning":false,"count":49700}}}"#;
1405 let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
1406 let status = wrapper.subsonic_response.scan_status.unwrap();
1407 assert_eq!(status.count, Some(49_700));
1408 }
1409
1410 #[test]
1411 fn test_deserialize_search_result_defaults_to_empty() {
1412 let result: SubsonicSearchResult = serde_json::from_str("{}").unwrap();
1414
1415 assert!(result.artist.is_empty());
1416 assert!(result.album.is_empty());
1417 assert!(result.song.is_empty());
1418 }
1419}