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,
51 pub credential: Credential,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum Credential {
57 Password(String),
58 ApiKey(String),
61}
62
63impl SubsonicAuth {
64 pub fn new(base_url: &str, username: &str, password: &str) -> Self {
65 Self::with(
66 base_url,
67 username,
68 Credential::Password(password.to_string()),
69 )
70 }
71
72 pub fn with(base_url: &str, username: &str, credential: Credential) -> Self {
73 Self {
74 base_url: base_url.trim_end_matches('/').to_string(),
75 username: username.to_string(),
76 credential,
77 }
78 }
79
80 fn params(&self) -> Result<HashMap<String, String>, SubsonicError> {
88 let mut params = HashMap::new();
89 match &self.credential {
90 Credential::ApiKey(key) => {
91 params.insert("apiKey".into(), key.clone());
92 }
93 Credential::Password(password) => {
94 params.insert("u".into(), self.username.clone());
95 if self.base_url.starts_with("https://") {
96 let hex: String = password.bytes().map(|b| format!("{b:02x}")).collect();
97 params.insert("p".into(), format!("enc:{hex}"));
98 } else {
99 let salt = random_salt()?;
100 let token = format!("{:x}", md5::compute(format!("{password}{salt}")));
101 params.insert("t".into(), token);
102 params.insert("s".into(), salt);
103 }
104 }
105 }
106 params.insert("v".into(), API_VERSION.into());
107 params.insert("c".into(), CLIENT_NAME.into());
108 params.insert("f".into(), "json".into());
109 Ok(params)
110 }
111
112 pub fn query(&self) -> Result<String, SubsonicError> {
115 Ok(self
116 .params()?
117 .iter()
118 .map(|(k, v)| format!("{}={}", k, v))
119 .collect::<Vec<_>>()
120 .join("&"))
121 }
122
123 pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
125 Ok(format!(
126 "{}/rest/stream?id={}&{}",
127 self.base_url,
128 track_id,
129 self.query()?
130 ))
131 }
132}
133
134pub struct SubsonicClient {
141 auth: SubsonicAuth,
142 http: reqwest::blocking::Client,
143 downloader: reqwest::blocking::Client,
144 outage: download::Outage,
146 playback_report: OnceLock<bool>,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum PlaybackReportState {
153 Playing,
154 Paused,
155 Stopped,
156}
157
158impl PlaybackReportState {
159 fn as_str(self) -> &'static str {
160 match self {
161 Self::Playing => "playing",
162 Self::Paused => "paused",
163 Self::Stopped => "stopped",
164 }
165 }
166}
167
168impl SubsonicClient {
169 pub fn new(base_url: &str, username: &str, password: &str) -> Self {
170 Self::from_auth(SubsonicAuth::new(base_url, username, password))
171 }
172
173 pub fn from_auth(auth: SubsonicAuth) -> Self {
174 Self {
175 auth,
176 http: download::api_client().unwrap_or_else(|e| {
177 log::warn!("falling back to default HTTP client: {}", e);
178 reqwest::blocking::Client::new()
179 }),
180 downloader: download::download_client().unwrap_or_else(|e| {
181 log::warn!("falling back to default download client: {}", e);
182 reqwest::blocking::Client::new()
183 }),
184 outage: download::Outage::default(),
185 playback_report: OnceLock::new(),
186 }
187 }
188
189 pub fn outage(&self) -> &download::Outage {
191 &self.outage
192 }
193
194 fn auth_params(&self) -> Result<HashMap<String, String>, SubsonicError> {
195 self.auth.params()
196 }
197
198 fn get(&self, endpoint: &str) -> Result<SubsonicResponse, SubsonicError> {
200 self.get_with_params(endpoint, &[])
201 }
202
203 fn get_with_params(
204 &self,
205 endpoint: &str,
206 extra: &[(&str, &str)],
207 ) -> Result<SubsonicResponse, SubsonicError> {
208 let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
209 let mut params = self.auth_params()?;
210 for (k, v) in extra {
211 params.insert((*k).to_string(), (*v).to_string());
212 }
213
214 let resp: SubsonicResponseWrapper = self.http.get(&url).query(¶ms).send()?.json()?;
215 resp.subsonic_response.ok()
216 }
217
218 fn reject_error_body(resp: &reqwest::blocking::Response) -> Result<(), SubsonicError> {
225 let is_document = resp
226 .headers()
227 .get(reqwest::header::CONTENT_TYPE)
228 .and_then(|v| v.to_str().ok())
229 .is_some_and(|ct| ct.contains("json") || ct.contains("xml"));
230 if is_document {
231 return Err(SubsonicError::BadResponse);
232 }
233 if !resp.status().is_success() {
234 return Err(SubsonicError::BadResponse);
235 }
236 Ok(())
237 }
238
239 pub fn get_cover_art(&self, id: &str, size: Option<u32>) -> Result<Vec<u8>, SubsonicError> {
245 let url = format!("{}/rest/getCoverArt", self.base_url());
246 let mut params = self.auth_params()?;
247 params.insert("id".into(), id.to_string());
248 if let Some(px) = size {
249 params.insert("size".into(), px.to_string());
250 }
251
252 let resp = self.http.get(&url).query(¶ms).send()?;
253 Self::reject_error_body(&resp)?;
254 Ok(resp.bytes()?.to_vec())
255 }
256
257 pub fn ping(&self) -> Result<(), SubsonicError> {
259 self.get("ping")?;
260 Ok(())
261 }
262
263 pub fn server_type(&self) -> Result<Option<String>, SubsonicError> {
265 Ok(self.get("ping")?.server_type)
266 }
267
268 pub fn profile(&self) -> Result<crate::remote::profile::ServerProfile, SubsonicError> {
272 let ping = self.get("ping")?;
273 let extensions = if ping.open_subsonic {
274 self.get("getOpenSubsonicExtensions")
275 .ok()
276 .and_then(|r| r.open_subsonic_extensions)
277 .unwrap_or_default()
278 .into_iter()
279 .map(|e| (e.name, e.versions))
280 .collect()
281 } else {
282 Vec::new()
283 };
284 Ok(crate::remote::profile::ServerProfile {
285 kind: ping.server_type,
286 version: ping.server_version,
287 open_subsonic: ping.open_subsonic,
288 extensions,
289 })
290 }
291
292 pub fn get_artists(&self) -> Result<Vec<SubsonicArtist>, SubsonicError> {
294 let resp = self.get("getArtists")?;
295 let artists_data = resp.artists.ok_or(SubsonicError::BadResponse)?;
296 let mut all = Vec::new();
297 for index in artists_data.index {
298 all.extend(index.artist);
299 }
300 Ok(all)
301 }
302
303 pub fn get_album(&self, id: &str) -> Result<SubsonicAlbumFull, SubsonicError> {
305 let resp = self.get_with_params("getAlbum", &[("id", id)])?;
306 resp.album.ok_or(SubsonicError::BadResponse)
307 }
308
309 pub fn get_album_list(
311 &self,
312 list_type: &str,
313 size: u32,
314 offset: u32,
315 ) -> Result<Vec<SubsonicAlbum>, SubsonicError> {
316 let size_str = size.to_string();
317 let offset_str = offset.to_string();
318 let resp = self.get_with_params(
319 "getAlbumList2",
320 &[
321 ("type", list_type),
322 ("size", &size_str),
323 ("offset", &offset_str),
324 ],
325 )?;
326 Ok(resp.album_list2.map(|al| al.album).unwrap_or_default())
327 }
328
329 pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
331 self.auth.stream_url(track_id)
332 }
333
334 pub fn stream_url_template(&self, track_id: &str) -> String {
336 format!("{}/rest/stream?id={}", self.auth.base_url, track_id)
337 }
338
339 pub fn download(&self, track_id: &str, dest: &Path) -> Result<(), SubsonicError> {
341 self.fetch_to_file("download", track_id, dest, None, |_, _| {})
342 }
343
344 pub fn download_with_progress(
353 &self,
354 track_id: &str,
355 dest: &Path,
356 cancelled: &dyn Fn() -> bool,
357 on_progress: impl Fn(u64, u64),
358 ) -> Result<(), SubsonicError> {
359 let patience = download::Patience {
360 outage: &self.outage,
361 cancelled,
362 };
363 self.fetch_to_file("download", track_id, dest, Some(patience), on_progress)
364 }
365
366 pub fn stream_to_file(
372 &self,
373 track_id: &str,
374 dest: &Path,
375 on_progress: impl Fn(u64, u64),
376 ) -> Result<(), SubsonicError> {
377 self.fetch_to_file("stream", track_id, dest, None, on_progress)
378 }
379
380 fn fetch_to_file(
381 &self,
382 endpoint: &str,
383 track_id: &str,
384 dest: &Path,
385 patience: Option<download::Patience<'_>>,
386 on_progress: impl Fn(u64, u64),
387 ) -> Result<(), SubsonicError> {
388 let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
389 download::download_with_retries(
390 dest,
391 download::DEFAULT_ATTEMPTS,
392 patience,
393 || {
394 let mut params = self
396 .auth_params()
397 .map_err(|e| download::DownloadError::Request(e.to_string()))?;
398 params.insert("id".into(), track_id.to_string());
399 Ok(self.downloader.get(&url).query(¶ms))
400 },
401 on_progress,
402 )?;
403 Ok(())
404 }
405
406 pub fn all_songs_page(
412 &self,
413 size: u32,
414 offset: u32,
415 ) -> Result<Vec<SubsonicSong>, SubsonicError> {
416 let size = size.to_string();
417 let offset = offset.to_string();
418 let resp = self.get_with_params(
419 "search3",
420 &[
421 ("query", ""),
422 ("artistCount", "0"),
423 ("albumCount", "0"),
424 ("songCount", &size),
425 ("songOffset", &offset),
426 ],
427 )?;
428 Ok(resp.search_result3.map(|r| r.song).unwrap_or_default())
429 }
430
431 pub fn song_count(&self) -> Result<Option<u64>, SubsonicError> {
434 let resp = self.get("getScanStatus")?;
435 Ok(resp.scan_status.and_then(|s| s.count))
436 }
437
438 pub fn library_modified(&self, since: Option<i64>) -> Result<Option<i64>, SubsonicError> {
443 let since = since.map(|v| v.to_string());
444 let params: Vec<(&str, &str)> = since
445 .as_deref()
446 .map(|v| vec![("ifModifiedSince", v)])
447 .unwrap_or_default();
448 let resp = self.get_with_params("getIndexes", ¶ms)?;
449 Ok(resp.indexes.and_then(|i| i.last_modified))
450 }
451
452 pub fn search(&self, query: &str) -> Result<SubsonicSearchResult, SubsonicError> {
454 let resp = self.get_with_params("search3", &[("query", query)])?;
455 Ok(resp.search_result3.unwrap_or_default())
456 }
457
458 pub fn scrobble(&self, track_id: &str, heard_at_ms: u64) -> Result<(), SubsonicError> {
461 let at = heard_at_ms.to_string();
462 self.get_with_params(
463 "scrobble",
464 &[("id", track_id), ("submission", "true"), ("time", &at)],
465 )?;
466 Ok(())
467 }
468
469 pub fn report_playback(
478 &self,
479 track_id: &str,
480 state: PlaybackReportState,
481 position_ms: u64,
482 ) -> Result<(), SubsonicError> {
483 if self.supports_playback_report() {
484 let position = position_ms.to_string();
485 self.get_with_params(
486 "reportPlayback",
487 &[
488 ("mediaId", track_id),
489 ("mediaType", "song"),
490 ("positionMs", &position),
491 ("state", state.as_str()),
492 ("ignoreScrobble", "true"),
493 ],
494 )?;
495 return Ok(());
496 }
497 if state == PlaybackReportState::Playing {
498 let position = (position_ms / 1000).to_string();
499 self.get_with_params(
500 "scrobble",
501 &[
502 ("id", track_id),
503 ("submission", "false"),
504 ("position", &position),
505 ],
506 )?;
507 }
508 Ok(())
509 }
510
511 fn supports_playback_report(&self) -> bool {
515 if let Some(&known) = self.playback_report.get() {
516 return known;
517 }
518 let supported = match self.get("getOpenSubsonicExtensions") {
519 Ok(resp) => resp.has_extension(PLAYBACK_REPORT_EXTENSION),
520 Err(SubsonicError::Http(e)) if e.is_connect() || e.is_timeout() => return false,
521 Err(_) => false,
522 };
523 *self.playback_report.get_or_init(|| supported)
524 }
525
526 pub fn star(&self, track_id: &str) -> Result<(), SubsonicError> {
528 self.get_with_params("star", &[("id", track_id)])?;
529 Ok(())
530 }
531
532 pub fn unstar(&self, track_id: &str) -> Result<(), SubsonicError> {
534 self.get_with_params("unstar", &[("id", track_id)])?;
535 Ok(())
536 }
537
538 pub fn get_starred_all(&self) -> Result<SubsonicStarred, SubsonicError> {
543 let resp = self.get("getStarred2")?;
544 Ok(resp.starred2.unwrap_or_default())
545 }
546
547 pub fn star_album(&self, album_id: &str) -> Result<(), SubsonicError> {
550 self.get_with_params("star", &[("albumId", album_id)])?;
551 Ok(())
552 }
553
554 pub fn unstar_album(&self, album_id: &str) -> Result<(), SubsonicError> {
555 self.get_with_params("unstar", &[("albumId", album_id)])?;
556 Ok(())
557 }
558
559 pub fn star_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
560 self.get_with_params("star", &[("artistId", artist_id)])?;
561 Ok(())
562 }
563
564 pub fn unstar_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
565 self.get_with_params("unstar", &[("artistId", artist_id)])?;
566 Ok(())
567 }
568
569 pub fn create_share(
572 &self,
573 ids: &[&str],
574 description: Option<&str>,
575 ) -> Result<SubsonicShare, SubsonicError> {
576 let url = format!("{}/rest/createShare", self.auth.base_url);
577 let mut params = self.auth_params()?;
578 if let Some(desc) = description {
579 params.insert("description".into(), desc.to_string());
580 }
581
582 let mut query: Vec<(String, String)> = params.into_iter().collect();
584 for id in ids {
585 query.push(("id".into(), (*id).to_string()));
586 }
587
588 let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
589
590 let inner = resp.subsonic_response;
591 if inner.status != "ok" {
592 if let Some(err) = inner.error {
593 return Err(SubsonicError::Api {
594 code: err.code,
595 message: err.message,
596 });
597 }
598 return Err(SubsonicError::BadResponse);
599 }
600
601 inner
602 .shares
603 .and_then(|s| s.share.into_iter().next())
604 .ok_or(SubsonicError::BadResponse)
605 }
606
607 pub fn get_similar_songs(
610 &self,
611 song_id: &str,
612 count: usize,
613 ) -> Result<Vec<SubsonicSong>, SubsonicError> {
614 let count_str = count.to_string();
615 let resp = self.get_with_params(
616 "getSimilarSongs2",
617 &[("id", song_id), ("count", &count_str)],
618 )?;
619 Ok(resp.similar_songs2.and_then(|s| s.song).unwrap_or_default())
620 }
621
622 pub fn get_playlists(&self) -> Result<Vec<SubsonicPlaylist>, SubsonicError> {
626 let resp = self.get("getPlaylists")?;
627 Ok(resp.playlists.map(|p| p.playlist).unwrap_or_default())
628 }
629
630 pub fn get_playlist(&self, id: &str) -> Result<SubsonicPlaylistFull, SubsonicError> {
632 let resp = self.get_with_params("getPlaylist", &[("id", id)])?;
633 resp.playlist.ok_or(SubsonicError::BadResponse)
634 }
635
636 pub fn create_playlist(
645 &self,
646 playlist_id: Option<&str>,
647 name: &str,
648 song_ids: &[String],
649 ) -> Result<Option<SubsonicPlaylistFull>, SubsonicError> {
650 let url = format!("{}/rest/createPlaylist", self.auth.base_url);
651 let mut params = self.auth_params()?;
652 match playlist_id {
653 Some(id) => {
654 params.insert("playlistId".into(), id.to_string());
655 params.insert("name".into(), name.to_string());
658 }
659 None => {
660 params.insert("name".into(), name.to_string());
661 }
662 }
663
664 let mut query: Vec<(String, String)> = params.into_iter().collect();
666 for id in song_ids {
667 query.push(("songId".into(), id.clone()));
668 }
669
670 let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
671 let inner = resp.subsonic_response;
672 if inner.status != "ok" {
673 if let Some(err) = inner.error {
674 return Err(SubsonicError::Api {
675 code: err.code,
676 message: err.message,
677 });
678 }
679 return Err(SubsonicError::BadResponse);
680 }
681 Ok(inner.playlist)
685 }
686
687 pub fn update_playlist(
689 &self,
690 id: &str,
691 name: Option<&str>,
692 comment: Option<&str>,
693 public: Option<bool>,
694 ) -> Result<(), SubsonicError> {
695 let mut extra: Vec<(&str, String)> = vec![("playlistId", id.to_string())];
696 if let Some(name) = name {
697 extra.push(("name", name.to_string()));
698 }
699 if let Some(comment) = comment {
700 extra.push(("comment", comment.to_string()));
701 }
702 if let Some(public) = public {
703 extra.push(("public", public.to_string()));
704 }
705 let borrowed: Vec<(&str, &str)> = extra.iter().map(|(k, v)| (*k, v.as_str())).collect();
706 self.get_with_params("updatePlaylist", &borrowed)?;
707 Ok(())
708 }
709
710 pub fn delete_playlist(&self, id: &str) -> Result<(), SubsonicError> {
711 self.get_with_params("deletePlaylist", &[("id", id)])?;
712 Ok(())
713 }
714
715 pub fn koan_users(&self) -> Result<Vec<KoanUser>, SubsonicError> {
718 Ok(self
719 .get("koanUsers")?
720 .users
721 .map(|u| u.user)
722 .unwrap_or_default())
723 }
724
725 pub fn koan_create_user(
727 &self,
728 username: &str,
729 role: &str,
730 ) -> Result<KoanInvite, SubsonicError> {
731 self.get_with_params("koanCreateUser", &[("username", username), ("role", role)])?
732 .invite
733 .ok_or(SubsonicError::BadResponse)
734 }
735
736 pub fn koan_invite(&self, username: &str, reset: bool) -> Result<KoanInvite, SubsonicError> {
739 let reset = if reset { "true" } else { "false" };
740 self.get_with_params("koanInvite", &[("username", username), ("reset", reset)])?
741 .invite
742 .ok_or(SubsonicError::BadResponse)
743 }
744
745 pub fn koan_revoke_own_key(&self) -> Result<(), SubsonicError> {
747 self.get("koanRevokeKey")?;
748 Ok(())
749 }
750
751 pub fn koan_set_user_role(&self, username: &str, role: &str) -> Result<(), SubsonicError> {
752 self.get_with_params("koanSetUserRole", &[("username", username), ("role", role)])?;
753 Ok(())
754 }
755
756 pub fn koan_delete_user(&self, username: &str) -> Result<(), SubsonicError> {
757 self.get_with_params("koanDeleteUser", &[("username", username)])?;
758 Ok(())
759 }
760
761 pub fn koan_command(&self, to: &str, command: &str) -> Result<(), SubsonicError> {
764 self.get_with_params("koanCommand", &[("to", to), ("command", command)])?;
765 Ok(())
766 }
767
768 pub fn auth(&self) -> &SubsonicAuth {
769 &self.auth
770 }
771
772 pub fn base_url(&self) -> &str {
774 &self.auth.base_url
775 }
776}
777
778#[derive(Debug, Deserialize)]
781struct SubsonicResponseWrapper {
782 #[serde(rename = "subsonic-response")]
783 subsonic_response: SubsonicResponse,
784}
785
786#[derive(Debug, Deserialize)]
787#[serde(rename_all = "camelCase")]
788struct SubsonicResponse {
789 status: String,
790 #[serde(rename = "type")]
793 server_type: Option<String>,
794 server_version: Option<String>,
795 #[serde(default)]
796 open_subsonic: bool,
797 open_subsonic_extensions: Option<Vec<SubsonicExtension>>,
798 error: Option<SubsonicApiError>,
799 artists: Option<SubsonicArtists>,
800 album: Option<SubsonicAlbumFull>,
801 album_list2: Option<SubsonicAlbumList>,
802 search_result3: Option<SubsonicSearchResult>,
803 starred2: Option<SubsonicStarred>,
804 shares: Option<SubsonicShares>,
805 similar_songs2: Option<SubsonicSimilarSongs>,
806 playlists: Option<SubsonicPlaylists>,
807 playlist: Option<SubsonicPlaylistFull>,
808 scan_status: Option<SubsonicScanStatus>,
809 indexes: Option<SubsonicIndexes>,
810 users: Option<KoanUsers>,
811 invite: Option<KoanInvite>,
812 join: Option<KoanJoined>,
813}
814
815#[derive(Debug, Deserialize)]
816struct KoanUsers {
817 #[serde(default)]
818 user: Vec<KoanUser>,
819}
820
821#[derive(Debug, Clone, Deserialize)]
823pub struct KoanUser {
824 pub username: String,
825 pub role: String,
826}
827
828#[derive(Debug, Clone, Deserialize)]
832pub struct KoanInvite {
833 pub username: String,
834 pub token: String,
835 pub password: Option<String>,
836}
837
838#[derive(Debug, Clone, Deserialize)]
841#[serde(rename_all = "camelCase")]
842pub struct KoanJoined {
843 pub username: String,
844 pub api_key: String,
845}
846
847pub fn redeem_invite(
850 base_url: &str,
851 token: &str,
852 device: &str,
853) -> Result<KoanJoined, SubsonicError> {
854 let url = format!("{}/rest/koanJoin", base_url.trim_end_matches('/'));
855 let resp: SubsonicResponseWrapper = download::api_client()?
856 .get(&url)
857 .query(&[
858 ("invite", token),
859 ("name", device),
860 ("v", API_VERSION),
861 ("c", CLIENT_NAME),
862 ("f", "json"),
863 ])
864 .send()?
865 .json()?;
866 resp.subsonic_response
867 .ok()?
868 .join
869 .ok_or(SubsonicError::BadResponse)
870}
871
872impl SubsonicResponse {
873 fn ok(self) -> Result<Self, SubsonicError> {
874 if self.status == "ok" {
875 return Ok(self);
876 }
877 Err(self
878 .error
879 .map_or(SubsonicError::BadResponse, |err| SubsonicError::Api {
880 code: err.code,
881 message: err.message,
882 }))
883 }
884
885 fn has_extension(&self, name: &str) -> bool {
886 self.open_subsonic_extensions
887 .iter()
888 .flatten()
889 .any(|ext| ext.name == name)
890 }
891}
892
893#[derive(Debug, Deserialize)]
894struct SubsonicExtension {
895 name: String,
896 #[serde(default)]
897 versions: Vec<i64>,
898}
899
900#[derive(Debug, Deserialize)]
901#[serde(rename_all = "camelCase")]
902struct SubsonicIndexes {
903 last_modified: Option<i64>,
904}
905
906#[derive(Debug, Deserialize)]
907struct SubsonicScanStatus {
908 count: Option<u64>,
909}
910
911#[derive(Debug, Deserialize)]
912struct SubsonicApiError {
913 code: i32,
914 message: String,
915}
916
917#[derive(Debug, Deserialize)]
918struct SubsonicArtists {
919 index: Vec<SubsonicArtistIndex>,
920}
921
922#[derive(Debug, Deserialize)]
923struct SubsonicArtistIndex {
924 artist: Vec<SubsonicArtist>,
925}
926
927#[derive(Debug, Clone, Deserialize)]
928#[serde(rename_all = "camelCase")]
929pub struct SubsonicArtist {
930 pub id: String,
931 pub name: String,
932 pub album_count: Option<i32>,
933 #[serde(default, deserialize_with = "non_empty")]
936 pub music_brainz_id: Option<String>,
937 #[serde(default, deserialize_with = "non_empty")]
938 pub sort_name: Option<String>,
939}
940
941#[derive(Debug, Clone, Deserialize)]
942#[serde(rename_all = "camelCase")]
943pub struct SubsonicAlbum {
944 pub id: String,
945 pub name: String,
946 pub artist: Option<String>,
947 pub artist_id: Option<String>,
948 pub song_count: Option<i32>,
949 #[serde(default)]
951 pub duration: Option<i64>,
952 pub year: Option<i32>,
953 #[serde(default, deserialize_with = "non_empty")]
954 pub genre: Option<String>,
955 pub created: Option<String>,
956 #[serde(default, deserialize_with = "non_empty")]
959 pub music_brainz_id: Option<String>,
960 #[serde(default, deserialize_with = "non_empty")]
961 pub sort_name: Option<String>,
962 #[serde(default)]
963 pub record_labels: Vec<SubsonicName>,
964}
965
966#[derive(Debug, Clone, Deserialize)]
969pub struct SubsonicName {
970 pub name: String,
971}
972
973#[derive(Debug, Clone, Deserialize)]
974#[serde(rename_all = "camelCase")]
975pub struct SubsonicAlbumFull {
976 pub id: String,
977 pub name: String,
978 pub artist: Option<String>,
979 pub artist_id: Option<String>,
980 pub year: Option<i32>,
981 #[serde(default, deserialize_with = "non_empty")]
982 pub genre: Option<String>,
983 pub song_count: Option<i32>,
984 pub created: Option<String>,
985 #[serde(default, deserialize_with = "non_empty")]
986 pub music_brainz_id: Option<String>,
987 #[serde(default, deserialize_with = "non_empty")]
988 pub sort_name: Option<String>,
989 #[serde(default)]
990 pub record_labels: Vec<SubsonicName>,
991 #[serde(default)]
992 pub song: Vec<SubsonicSong>,
993}
994
995#[derive(Debug, Clone, Deserialize)]
996#[serde(rename_all = "camelCase")]
997pub struct SubsonicSong {
998 pub id: String,
999 pub title: String,
1000 pub album: Option<String>,
1001 pub artist: Option<String>,
1002 pub track: Option<i32>,
1003 pub disc_number: Option<i32>,
1004 pub year: Option<i32>,
1005 #[serde(default, deserialize_with = "non_empty")]
1006 pub genre: Option<String>,
1007 pub duration: Option<i64>,
1008 pub bit_rate: Option<i32>,
1009 pub suffix: Option<String>,
1010 pub content_type: Option<String>,
1011 #[serde(default, deserialize_with = "non_empty")]
1013 pub codec: Option<String>,
1014 pub album_id: Option<String>,
1015 pub artist_id: Option<String>,
1016 pub sampling_rate: Option<i32>,
1019 pub bit_depth: Option<i32>,
1020 pub channel_count: Option<i32>,
1021 #[serde(default, deserialize_with = "non_empty")]
1022 pub music_brainz_id: Option<String>,
1023}
1024
1025#[derive(Debug, Deserialize)]
1026struct SubsonicAlbumList {
1027 #[serde(default)]
1028 album: Vec<SubsonicAlbum>,
1029}
1030
1031#[derive(Debug, Default, Deserialize)]
1032pub struct SubsonicSearchResult {
1033 #[serde(default)]
1034 pub artist: Vec<SubsonicArtist>,
1035 #[serde(default)]
1036 pub album: Vec<SubsonicAlbum>,
1037 #[serde(default)]
1038 pub song: Vec<SubsonicSong>,
1039}
1040
1041#[derive(Debug, Default, Deserialize)]
1042pub struct SubsonicStarred {
1043 #[serde(default)]
1044 pub song: Vec<SubsonicSong>,
1045 #[serde(default)]
1046 pub album: Vec<SubsonicAlbum>,
1047 #[serde(default)]
1048 pub artist: Vec<SubsonicArtist>,
1049}
1050
1051#[derive(Debug, Deserialize)]
1052pub struct SubsonicSimilarSongs {
1053 pub song: Option<Vec<SubsonicSong>>,
1054}
1055
1056#[derive(Debug, Default, Deserialize)]
1057struct SubsonicPlaylists {
1058 #[serde(default)]
1059 playlist: Vec<SubsonicPlaylist>,
1060}
1061
1062#[derive(Debug, Clone, Deserialize)]
1064#[serde(rename_all = "camelCase")]
1065pub struct SubsonicPlaylist {
1066 pub id: String,
1067 pub name: String,
1068 pub comment: Option<String>,
1069 pub owner: Option<String>,
1070 #[serde(default)]
1071 pub public: bool,
1072 pub song_count: Option<i64>,
1073 pub duration: Option<i64>,
1074 pub created: Option<String>,
1075 pub changed: Option<String>,
1076}
1077
1078#[derive(Debug, Clone, Deserialize)]
1079#[serde(rename_all = "camelCase")]
1080pub struct SubsonicPlaylistFull {
1081 #[serde(flatten)]
1082 pub playlist: SubsonicPlaylist,
1083 #[serde(default)]
1084 pub entry: Vec<SubsonicSong>,
1085}
1086
1087#[derive(Debug, Deserialize)]
1088struct SubsonicShares {
1089 #[serde(default)]
1090 share: Vec<SubsonicShare>,
1091}
1092
1093#[derive(Debug, Clone, Deserialize)]
1094#[serde(rename_all = "camelCase")]
1095pub struct SubsonicShare {
1096 pub id: String,
1097 pub url: Option<String>,
1098 pub description: Option<String>,
1099 pub username: Option<String>,
1100 pub created: Option<String>,
1101 pub expires: Option<String>,
1102 pub visit_count: Option<i64>,
1103}
1104
1105fn non_empty<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
1112 Ok(Option::<String>::deserialize(d)?.filter(|s| !s.is_empty()))
1113}
1114
1115fn random_salt() -> Result<String, getrandom::Error> {
1122 let mut buf = [0u8; 12];
1123 getrandom::fill(&mut buf)?;
1124 Ok(buf.iter().map(|b| format!("{:02x}", b)).collect())
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129 use super::*;
1130
1131 fn response(json: &str) -> SubsonicResponse {
1132 serde_json::from_str::<SubsonicResponseWrapper>(json)
1133 .unwrap()
1134 .subsonic_response
1135 }
1136
1137 #[test]
1138 fn http_errors_leave_the_signed_url_out() {
1139 let e = reqwest::blocking::get("http://127.0.0.1:1/rest/stream?u=owner&p=enc:736563726574")
1141 .unwrap_err();
1142 assert!(e.to_string().contains("enc:736563726574"));
1143 let shown = SubsonicError::from(e).to_string();
1144 assert!(!shown.contains("enc:"), "{shown}");
1145 let e = reqwest::blocking::get("http://127.0.0.1:1/rest/stream?p=enc:736563726574")
1146 .unwrap_err();
1147 assert!(!DownloadError::from(e).to_string().contains("enc:"));
1148 }
1149
1150 #[test]
1151 fn playback_report_is_read_from_the_advertised_extensions() {
1152 let navidrome = response(
1153 r#"{"subsonic-response": {
1154 "status": "ok", "version": "1.16.1", "type": "navidrome",
1155 "openSubsonic": true,
1156 "openSubsonicExtensions": [
1157 {"name": "transcodeOffset", "versions": [1]},
1158 {"name": "playbackReport", "versions": [1]}
1159 ]
1160 }}"#,
1161 );
1162 assert!(navidrome.has_extension(PLAYBACK_REPORT_EXTENSION));
1163
1164 let without = response(
1165 r#"{"subsonic-response": {
1166 "status": "ok", "version": "1.16.1",
1167 "openSubsonicExtensions": [{"name": "songLyrics", "versions": [1, 2]}]
1168 }}"#,
1169 );
1170 assert!(!without.has_extension(PLAYBACK_REPORT_EXTENSION));
1171
1172 let plain = response(r#"{"subsonic-response": {"status": "ok", "version": "1.16.1"}}"#);
1173 assert!(!plain.has_extension(PLAYBACK_REPORT_EXTENSION));
1174 }
1175
1176 #[test]
1179 fn test_deserialize_subsonic_song() {
1180 let json = r#"{
1181 "id": "42",
1182 "title": "Space Oddity",
1183 "album": "Space Oddity",
1184 "artist": "David Bowie",
1185 "track": 1,
1186 "discNumber": 1,
1187 "year": 1969,
1188 "genre": "Rock",
1189 "duration": 314,
1190 "bitRate": 320,
1191 "suffix": "mp3",
1192 "contentType": "audio/mpeg",
1193 "albumId": "7",
1194 "artistId": "3"
1195 }"#;
1196
1197 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1198
1199 assert_eq!(song.id, "42");
1200 assert_eq!(song.title, "Space Oddity");
1201 assert_eq!(song.album.as_deref(), Some("Space Oddity"));
1202 assert_eq!(song.artist.as_deref(), Some("David Bowie"));
1203 assert_eq!(song.track, Some(1));
1204 assert_eq!(song.disc_number, Some(1));
1205 assert_eq!(song.year, Some(1969));
1206 assert_eq!(song.genre.as_deref(), Some("Rock"));
1207 assert_eq!(song.duration, Some(314));
1208 assert_eq!(song.bit_rate, Some(320));
1209 assert_eq!(song.suffix.as_deref(), Some("mp3"));
1210 assert_eq!(song.content_type.as_deref(), Some("audio/mpeg"));
1211 assert_eq!(song.album_id.as_deref(), Some("7"));
1212 assert_eq!(song.artist_id.as_deref(), Some("3"));
1213 }
1214
1215 #[test]
1219 fn opensubsonic_quality_fields_are_read() {
1220 let json = r#"{
1221 "id": "000XtGC7jsWEbOjDsZi4Xw",
1222 "title": "Anguish",
1223 "suffix": "flac",
1224 "bitRate": 913,
1225 "samplingRate": 44100,
1226 "bitDepth": 16,
1227 "channelCount": 2
1228 }"#;
1229
1230 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1231
1232 assert_eq!(song.sampling_rate, Some(44100));
1233 assert_eq!(song.bit_depth, Some(16));
1234 assert_eq!(song.channel_count, Some(2));
1235 }
1236
1237 #[test]
1240 fn a_koan_server_names_the_codec() {
1241 let json = r#"{"id": "1", "title": "Seawhite", "suffix": "m4a", "codec": "ALAC"}"#;
1242 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1243 assert_eq!(song.codec.as_deref(), Some("ALAC"));
1244
1245 let json = r#"{"id": "1", "title": "Seawhite", "suffix": "m4a"}"#;
1246 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1247 assert_eq!(song.codec, None);
1248 }
1249
1250 #[test]
1253 fn a_plain_subsonic_song_has_no_quality_figures() {
1254 let json = r#"{"id": "1", "title": "Track", "bitRate": 320}"#;
1255 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1256
1257 assert_eq!(song.sampling_rate, None);
1258 assert_eq!(song.bit_depth, None);
1259 assert_eq!(song.channel_count, None);
1260 }
1261
1262 #[test]
1263 fn test_deserialize_subsonic_song_optional_fields_absent() {
1264 let json = r#"{"id": "99", "title": "Minimal Track"}"#;
1266
1267 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1268
1269 assert_eq!(song.id, "99");
1270 assert_eq!(song.title, "Minimal Track");
1271 assert!(song.album.is_none());
1272 assert!(song.artist.is_none());
1273 assert!(song.track.is_none());
1274 assert!(song.disc_number.is_none());
1275 assert!(song.year.is_none());
1276 assert!(song.duration.is_none());
1277 assert!(song.bit_rate.is_none());
1278 }
1279
1280 #[test]
1283 fn test_deserialize_album_list() {
1284 let json = r#"{
1285 "subsonic-response": {
1286 "status": "ok",
1287 "version": "1.16.1",
1288 "albumList2": {
1289 "album": [
1290 {
1291 "id": "1",
1292 "name": "Abbey Road",
1293 "artist": "The Beatles",
1294 "artistId": "10",
1295 "songCount": 17,
1296 "year": 1969,
1297 "genre": "Rock",
1298 "created": "2020-01-01T00:00:00"
1299 },
1300 {
1301 "id": "2",
1302 "name": "Led Zeppelin IV",
1303 "artist": "Led Zeppelin",
1304 "artistId": "11",
1305 "songCount": 8,
1306 "year": 1971,
1307 "genre": "Hard Rock",
1308 "created": "2020-01-02T00:00:00"
1309 }
1310 ]
1311 }
1312 }
1313 }"#;
1314
1315 let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
1316 let album_list = wrapper
1317 .subsonic_response
1318 .album_list2
1319 .expect("album_list2 should be present");
1320
1321 assert_eq!(album_list.album.len(), 2);
1322
1323 let first = &album_list.album[0];
1324 assert_eq!(first.id, "1");
1325 assert_eq!(first.name, "Abbey Road");
1326 assert_eq!(first.artist.as_deref(), Some("The Beatles"));
1327 assert_eq!(first.artist_id.as_deref(), Some("10"));
1328 assert_eq!(first.song_count, Some(17));
1329 assert_eq!(first.year, Some(1969));
1330
1331 let second = &album_list.album[1];
1332 assert_eq!(second.id, "2");
1333 assert_eq!(second.name, "Led Zeppelin IV");
1334 assert_eq!(second.song_count, Some(8));
1335 }
1336
1337 #[test]
1340 fn test_auth_params_format() {
1341 let client = SubsonicClient::new("http://localhost:4533", "alice", "secret");
1342 let params = client.auth_params().unwrap();
1343
1344 assert!(params.contains_key("u"), "missing 'u' param");
1346 assert!(params.contains_key("t"), "missing 't' param");
1347 assert!(params.contains_key("s"), "missing 's' param");
1348 assert!(params.contains_key("v"), "missing 'v' param");
1349 assert!(params.contains_key("c"), "missing 'c' param");
1350 assert!(params.contains_key("f"), "missing 'f' param");
1351 assert_eq!(params.len(), 6);
1352
1353 assert_eq!(params["u"], "alice");
1354 assert_eq!(params["v"], "1.16.1");
1355 assert_eq!(params["c"], "koan");
1356 assert_eq!(params["f"], "json");
1357 }
1358
1359 #[test]
1360 fn test_auth_params_over_https_send_the_hex_password() {
1361 let client = SubsonicClient::new("https://koan.example", "alice", "hi");
1362 let params = client.auth_params().unwrap();
1363 assert_eq!(params["p"], "enc:6869");
1364 assert!(!params.contains_key("t") && !params.contains_key("s"));
1365 }
1366
1367 #[test]
1368 fn test_auth_params_with_an_api_key_name_no_user() {
1369 let auth = SubsonicAuth::with(
1370 "http://koan.example",
1371 "alice",
1372 Credential::ApiKey("k3y".into()),
1373 );
1374 let params = auth.params().unwrap();
1375 assert_eq!(params.get("apiKey").map(String::as_str), Some("k3y"));
1376 for absent in ["u", "p", "t", "s"] {
1377 assert!(!params.contains_key(absent), "{absent}");
1378 }
1379 }
1380
1381 #[test]
1382 fn test_auth_params_token_is_md5_of_password_plus_salt() {
1383 let client = SubsonicClient::new("http://localhost:4533", "bob", "letmein");
1384 let params = client.auth_params().unwrap();
1385
1386 let salt = ¶ms["s"];
1387 let token = ¶ms["t"];
1388
1389 let expected = format!("{:x}", md5::compute(format!("letmein{}", salt)));
1391 assert_eq!(token, &expected);
1392 }
1393
1394 #[test]
1395 fn test_auth_params_salt_is_different_each_call() {
1396 let client = SubsonicClient::new("http://localhost:4533", "user", "pass");
1397 let params1 = client.auth_params().unwrap();
1398 let params2 = client.auth_params().unwrap();
1399
1400 assert_ne!(params1["s"], params2["s"], "salt should be random per call");
1403 }
1404
1405 #[test]
1408 fn test_stream_url_has_auth() {
1409 let client = SubsonicClient::new("http://myserver:4533", "user", "pass");
1410 let url = client.stream_url("track-123").unwrap();
1411
1412 assert!(url.contains("track-123"), "url must include the track id");
1413 assert!(url.contains("u=user"), "url must include username param");
1414 assert!(url.contains("v=1.16.1"), "url must include api version");
1415 assert!(url.contains("c=koan"), "url must include client name");
1416 assert!(url.contains("f=json"), "url must include format param");
1417 assert!(url.contains("/rest/stream"), "url must target /rest/stream");
1418 assert!(
1419 url.starts_with("http://myserver:4533"),
1420 "url must use the configured base_url"
1421 );
1422 }
1423
1424 #[test]
1425 fn test_stream_url_base_url_trailing_slash_normalised() {
1426 let client_with_slash = SubsonicClient::new("http://myserver:4533/", "u", "p");
1428 let client_no_slash = SubsonicClient::new("http://myserver:4533", "u", "p");
1429
1430 let url_with = client_with_slash.stream_url("1").unwrap();
1431 let url_without = client_no_slash.stream_url("1").unwrap();
1432
1433 assert!(
1435 url_with.contains("/rest/stream"),
1436 "should not have double slash"
1437 );
1438 assert!(!url_with.contains("//rest"), "should not have double slash");
1439 assert_eq!(
1441 url_with.split('?').next(),
1442 url_without.split('?').next(),
1443 "path segment should be identical regardless of trailing slash"
1444 );
1445 }
1446
1447 #[test]
1450 fn test_deserialize_album_full_with_songs() {
1451 let json = r#"{
1452 "id": "5",
1453 "name": "Kind of Blue",
1454 "artist": "Miles Davis",
1455 "artistId": "20",
1456 "year": 1959,
1457 "genre": "Jazz",
1458 "songCount": 5,
1459 "created": "2021-06-01T00:00:00",
1460 "song": [
1461 {"id": "101", "title": "So What"},
1462 {"id": "102", "title": "Freddie Freeloader"},
1463 {"id": "103", "title": "Blue in Green"}
1464 ]
1465 }"#;
1466
1467 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1468
1469 assert_eq!(album.id, "5");
1470 assert_eq!(album.name, "Kind of Blue");
1471 assert_eq!(album.artist.as_deref(), Some("Miles Davis"));
1472 assert_eq!(album.year, Some(1959));
1473 assert_eq!(album.song.len(), 3);
1474 assert_eq!(album.song[0].title, "So What");
1475 assert_eq!(album.song[2].id, "103");
1476 }
1477
1478 #[test]
1479 fn test_deserialize_album_full_empty_song_list() {
1480 let json = r#"{"id": "9", "name": "No Tracks Yet"}"#;
1482
1483 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1484
1485 assert_eq!(album.id, "9");
1486 assert!(album.song.is_empty(), "song list should default to empty");
1487 }
1488
1489 #[test]
1492 fn test_deserialize_search_result_mixed() {
1493 let json = r#"{
1494 "artist": [{"id": "1", "name": "Artist One"}],
1495 "album": [{"id": "2", "name": "Album One"}],
1496 "song": [{"id": "3", "title": "Song One"}]
1497 }"#;
1498
1499 let result: SubsonicSearchResult = serde_json::from_str(json).unwrap();
1500
1501 assert_eq!(result.artist.len(), 1);
1502 assert_eq!(result.artist[0].name, "Artist One");
1503 assert_eq!(result.album.len(), 1);
1504 assert_eq!(result.album[0].name, "Album One");
1505 assert_eq!(result.song.len(), 1);
1506 assert_eq!(result.song[0].title, "Song One");
1507 }
1508
1509 #[test]
1510 fn empty_opensubsonic_ids_are_absent() {
1511 let json = r#"{"id": "1", "title": "T", "musicBrainzId": ""}"#;
1512 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1513 assert_eq!(song.music_brainz_id, None);
1514
1515 let json = r#"{"id": "2", "name": "A", "musicBrainzId": "", "sortName": ""}"#;
1516 let album: SubsonicAlbum = serde_json::from_str(json).unwrap();
1517 assert_eq!((album.music_brainz_id, album.sort_name), (None, None));
1518
1519 let json = r#"{"id": "3", "name": "A", "musicBrainzId": "mb-1"}"#;
1520 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1521 assert_eq!(album.music_brainz_id.as_deref(), Some("mb-1"));
1522 assert_eq!(album.sort_name, None);
1523 }
1524
1525 #[test]
1526 fn test_deserialize_scan_status_count() {
1527 let json = r#"{"subsonic-response":{"status":"ok","scanStatus":{"scanning":false,"count":49700}}}"#;
1528 let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
1529 let status = wrapper.subsonic_response.scan_status.unwrap();
1530 assert_eq!(status.count, Some(49_700));
1531 }
1532
1533 #[test]
1534 fn test_deserialize_search_result_defaults_to_empty() {
1535 let result: SubsonicSearchResult = serde_json::from_str("{}").unwrap();
1537
1538 assert!(result.artist.is_empty());
1539 assert!(result.album.is_empty());
1540 assert!(result.song.is_empty());
1541 }
1542}