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 post_with_params(
222 &self,
223 endpoint: &str,
224 extra: &[(&str, &str)],
225 ) -> Result<SubsonicResponse, SubsonicError> {
226 let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
227 let mut params = self.auth_params()?;
228 for (k, v) in extra {
229 params.insert((*k).to_string(), (*v).to_string());
230 }
231 let resp: SubsonicResponseWrapper = self.http.post(&url).form(¶ms).send()?.json()?;
232 resp.subsonic_response.ok()
233 }
234
235 fn get_with_pairs(
238 &self,
239 endpoint: &str,
240 pairs: &[(&str, String)],
241 ) -> Result<SubsonicResponse, SubsonicError> {
242 let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
243 let params = self.auth_params()?;
244 let resp: SubsonicResponseWrapper = self
245 .http
246 .get(&url)
247 .query(¶ms)
248 .query(pairs)
249 .send()?
250 .json()?;
251 resp.subsonic_response.ok()
252 }
253
254 fn reject_error_body(resp: &reqwest::blocking::Response) -> Result<(), SubsonicError> {
261 let is_document = resp
262 .headers()
263 .get(reqwest::header::CONTENT_TYPE)
264 .and_then(|v| v.to_str().ok())
265 .is_some_and(|ct| ct.contains("json") || ct.contains("xml"));
266 if is_document {
267 return Err(SubsonicError::BadResponse);
268 }
269 if !resp.status().is_success() {
270 return Err(SubsonicError::BadResponse);
271 }
272 Ok(())
273 }
274
275 pub fn get_cover_art(&self, id: &str, size: Option<u32>) -> Result<Vec<u8>, SubsonicError> {
281 let url = format!("{}/rest/getCoverArt", self.base_url());
282 let mut params = self.auth_params()?;
283 params.insert("id".into(), id.to_string());
284 if let Some(px) = size {
285 params.insert("size".into(), px.to_string());
286 }
287
288 let resp = self.http.get(&url).query(¶ms).send()?;
289 Self::reject_error_body(&resp)?;
290 Ok(resp.bytes()?.to_vec())
291 }
292
293 pub fn ping(&self) -> Result<(), SubsonicError> {
295 self.get("ping")?;
296 Ok(())
297 }
298
299 pub fn server_type(&self) -> Result<Option<String>, SubsonicError> {
301 Ok(self.get("ping")?.server_type)
302 }
303
304 pub fn profile(&self) -> Result<crate::remote::profile::ServerProfile, SubsonicError> {
308 let ping = self.get("ping")?;
309 let extensions = if ping.open_subsonic {
310 self.get("getOpenSubsonicExtensions")
311 .ok()
312 .and_then(|r| r.open_subsonic_extensions)
313 .unwrap_or_default()
314 .into_iter()
315 .map(|e| (e.name, e.versions))
316 .collect()
317 } else {
318 Vec::new()
319 };
320 Ok(crate::remote::profile::ServerProfile {
321 kind: ping.server_type,
322 version: ping.server_version,
323 open_subsonic: ping.open_subsonic,
324 extensions,
325 })
326 }
327
328 pub fn get_artists(&self) -> Result<Vec<SubsonicArtist>, SubsonicError> {
330 let resp = self.get("getArtists")?;
331 let artists_data = resp.artists.ok_or(SubsonicError::BadResponse)?;
332 let mut all = Vec::new();
333 for index in artists_data.index {
334 all.extend(index.artist);
335 }
336 Ok(all)
337 }
338
339 pub fn get_album(&self, id: &str) -> Result<SubsonicAlbumFull, SubsonicError> {
341 let resp = self.get_with_params("getAlbum", &[("id", id)])?;
342 resp.album.ok_or(SubsonicError::BadResponse)
343 }
344
345 pub fn get_album_list(
347 &self,
348 list_type: &str,
349 size: u32,
350 offset: u32,
351 ) -> Result<Vec<SubsonicAlbum>, SubsonicError> {
352 let size_str = size.to_string();
353 let offset_str = offset.to_string();
354 let resp = self.get_with_params(
355 "getAlbumList2",
356 &[
357 ("type", list_type),
358 ("size", &size_str),
359 ("offset", &offset_str),
360 ],
361 )?;
362 Ok(resp.album_list2.map(|al| al.album).unwrap_or_default())
363 }
364
365 pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
367 self.auth.stream_url(track_id)
368 }
369
370 pub fn stream_url_template(&self, track_id: &str) -> String {
372 format!("{}/rest/stream?id={}", self.auth.base_url, track_id)
373 }
374
375 pub fn download(&self, track_id: &str, dest: &Path) -> Result<(), SubsonicError> {
377 self.fetch_to_file("download", track_id, dest, None, |_, _| {})
378 }
379
380 pub fn download_with_progress(
389 &self,
390 track_id: &str,
391 dest: &Path,
392 cancelled: &dyn Fn() -> bool,
393 on_progress: impl Fn(u64, u64),
394 ) -> Result<(), SubsonicError> {
395 let patience = download::Patience {
396 outage: &self.outage,
397 cancelled,
398 };
399 self.fetch_to_file("download", track_id, dest, Some(patience), on_progress)
400 }
401
402 pub fn stream_to_file(
408 &self,
409 track_id: &str,
410 dest: &Path,
411 on_progress: impl Fn(u64, u64),
412 ) -> Result<(), SubsonicError> {
413 self.fetch_to_file("stream", track_id, dest, None, on_progress)
414 }
415
416 fn fetch_to_file(
417 &self,
418 endpoint: &str,
419 track_id: &str,
420 dest: &Path,
421 patience: Option<download::Patience<'_>>,
422 on_progress: impl Fn(u64, u64),
423 ) -> Result<(), SubsonicError> {
424 let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
425 download::download_with_retries(
426 dest,
427 download::DEFAULT_ATTEMPTS,
428 patience,
429 || {
430 let mut params = self
432 .auth_params()
433 .map_err(|e| download::DownloadError::Request(e.to_string()))?;
434 params.insert("id".into(), track_id.to_string());
435 Ok(self.downloader.get(&url).query(¶ms))
436 },
437 on_progress,
438 )?;
439 Ok(())
440 }
441
442 pub fn all_songs_page(
448 &self,
449 size: u32,
450 offset: u32,
451 ) -> Result<Vec<SubsonicSong>, SubsonicError> {
452 let size = size.to_string();
453 let offset = offset.to_string();
454 let resp = self.get_with_params(
455 "search3",
456 &[
457 ("query", ""),
458 ("artistCount", "0"),
459 ("albumCount", "0"),
460 ("songCount", &size),
461 ("songOffset", &offset),
462 ],
463 )?;
464 Ok(resp.search_result3.map(|r| r.song).unwrap_or_default())
465 }
466
467 pub fn song_count(&self) -> Result<Option<u64>, SubsonicError> {
470 let resp = self.get("getScanStatus")?;
471 Ok(resp.scan_status.and_then(|s| s.count))
472 }
473
474 pub fn library_modified(&self, since: Option<i64>) -> Result<Option<i64>, SubsonicError> {
479 let since = since.map(|v| v.to_string());
480 let params: Vec<(&str, &str)> = since
481 .as_deref()
482 .map(|v| vec![("ifModifiedSince", v)])
483 .unwrap_or_default();
484 let resp = self.get_with_params("getIndexes", ¶ms)?;
485 Ok(resp.indexes.and_then(|i| i.last_modified))
486 }
487
488 pub fn search(&self, query: &str) -> Result<SubsonicSearchResult, SubsonicError> {
490 let resp = self.get_with_params("search3", &[("query", query)])?;
491 Ok(resp.search_result3.unwrap_or_default())
492 }
493
494 pub fn scrobble(&self, track_id: &str, heard_at_ms: u64) -> Result<(), SubsonicError> {
497 let at = heard_at_ms.to_string();
498 self.get_with_params(
499 "scrobble",
500 &[("id", track_id), ("submission", "true"), ("time", &at)],
501 )?;
502 Ok(())
503 }
504
505 pub fn scrobble_many(&self, plays: &[(&str, i64)]) -> Result<(), SubsonicError> {
507 let mut pairs = vec![("submission", "true".to_owned())];
508 for (id, at) in plays {
509 pairs.push(("id", (*id).to_owned()));
510 pairs.push(("time", at.to_string()));
511 }
512 self.get_with_pairs("scrobble", &pairs)?;
513 Ok(())
514 }
515
516 pub fn report_playback(
525 &self,
526 track_id: &str,
527 state: PlaybackReportState,
528 position_ms: u64,
529 ) -> Result<(), SubsonicError> {
530 if self.supports_playback_report() {
531 let position = position_ms.to_string();
532 self.get_with_params(
533 "reportPlayback",
534 &[
535 ("mediaId", track_id),
536 ("mediaType", "song"),
537 ("positionMs", &position),
538 ("state", state.as_str()),
539 ("ignoreScrobble", "true"),
540 ],
541 )?;
542 return Ok(());
543 }
544 if state == PlaybackReportState::Playing {
545 let position = (position_ms / 1000).to_string();
546 self.get_with_params(
547 "scrobble",
548 &[
549 ("id", track_id),
550 ("submission", "false"),
551 ("position", &position),
552 ],
553 )?;
554 }
555 Ok(())
556 }
557
558 fn supports_playback_report(&self) -> bool {
562 if let Some(&known) = self.playback_report.get() {
563 return known;
564 }
565 let supported = match self.get("getOpenSubsonicExtensions") {
566 Ok(resp) => resp.has_extension(PLAYBACK_REPORT_EXTENSION),
567 Err(SubsonicError::Http(e)) if e.is_connect() || e.is_timeout() => return false,
568 Err(_) => false,
569 };
570 *self.playback_report.get_or_init(|| supported)
571 }
572
573 pub fn star(&self, track_id: &str) -> Result<(), SubsonicError> {
575 self.get_with_params("star", &[("id", track_id)])?;
576 Ok(())
577 }
578
579 pub fn unstar(&self, track_id: &str) -> Result<(), SubsonicError> {
581 self.get_with_params("unstar", &[("id", track_id)])?;
582 Ok(())
583 }
584
585 pub fn get_starred_all(&self) -> Result<SubsonicStarred, SubsonicError> {
590 let resp = self.get("getStarred2")?;
591 Ok(resp.starred2.unwrap_or_default())
592 }
593
594 pub fn star_album(&self, album_id: &str) -> Result<(), SubsonicError> {
597 self.get_with_params("star", &[("albumId", album_id)])?;
598 Ok(())
599 }
600
601 pub fn unstar_album(&self, album_id: &str) -> Result<(), SubsonicError> {
602 self.get_with_params("unstar", &[("albumId", album_id)])?;
603 Ok(())
604 }
605
606 pub fn star_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
607 self.get_with_params("star", &[("artistId", artist_id)])?;
608 Ok(())
609 }
610
611 pub fn unstar_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
612 self.get_with_params("unstar", &[("artistId", artist_id)])?;
613 Ok(())
614 }
615
616 pub fn create_share(
619 &self,
620 ids: &[&str],
621 description: Option<&str>,
622 ) -> Result<SubsonicShare, SubsonicError> {
623 let url = format!("{}/rest/createShare", self.auth.base_url);
624 let mut params = self.auth_params()?;
625 if let Some(desc) = description {
626 params.insert("description".into(), desc.to_string());
627 }
628
629 let mut query: Vec<(String, String)> = params.into_iter().collect();
631 for id in ids {
632 query.push(("id".into(), (*id).to_string()));
633 }
634
635 let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
636
637 let inner = resp.subsonic_response;
638 if inner.status != "ok" {
639 if let Some(err) = inner.error {
640 return Err(SubsonicError::Api {
641 code: err.code,
642 message: err.message,
643 });
644 }
645 return Err(SubsonicError::BadResponse);
646 }
647
648 inner
649 .shares
650 .and_then(|s| s.share.into_iter().next())
651 .ok_or(SubsonicError::BadResponse)
652 }
653
654 pub fn get_similar_songs(
657 &self,
658 song_id: &str,
659 count: usize,
660 ) -> Result<Vec<SubsonicSong>, SubsonicError> {
661 let count_str = count.to_string();
662 let resp = self.get_with_params(
663 "getSimilarSongs2",
664 &[("id", song_id), ("count", &count_str)],
665 )?;
666 Ok(resp.similar_songs2.and_then(|s| s.song).unwrap_or_default())
667 }
668
669 pub fn get_playlists(&self) -> Result<Vec<SubsonicPlaylist>, SubsonicError> {
673 let resp = self.get("getPlaylists")?;
674 Ok(resp.playlists.map(|p| p.playlist).unwrap_or_default())
675 }
676
677 pub fn get_playlist(&self, id: &str) -> Result<SubsonicPlaylistFull, SubsonicError> {
679 let resp = self.get_with_params("getPlaylist", &[("id", id)])?;
680 resp.playlist.ok_or(SubsonicError::BadResponse)
681 }
682
683 pub fn create_playlist(
692 &self,
693 playlist_id: Option<&str>,
694 name: &str,
695 song_ids: &[String],
696 ) -> Result<Option<SubsonicPlaylistFull>, SubsonicError> {
697 let url = format!("{}/rest/createPlaylist", self.auth.base_url);
698 let mut params = self.auth_params()?;
699 match playlist_id {
700 Some(id) => {
701 params.insert("playlistId".into(), id.to_string());
702 params.insert("name".into(), name.to_string());
705 }
706 None => {
707 params.insert("name".into(), name.to_string());
708 }
709 }
710
711 let mut query: Vec<(String, String)> = params.into_iter().collect();
713 for id in song_ids {
714 query.push(("songId".into(), id.clone()));
715 }
716
717 let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
718 let inner = resp.subsonic_response;
719 if inner.status != "ok" {
720 if let Some(err) = inner.error {
721 return Err(SubsonicError::Api {
722 code: err.code,
723 message: err.message,
724 });
725 }
726 return Err(SubsonicError::BadResponse);
727 }
728 Ok(inner.playlist)
732 }
733
734 pub fn update_playlist(
736 &self,
737 id: &str,
738 name: Option<&str>,
739 comment: Option<&str>,
740 public: Option<bool>,
741 ) -> Result<(), SubsonicError> {
742 let mut extra: Vec<(&str, String)> = vec![("playlistId", id.to_string())];
743 if let Some(name) = name {
744 extra.push(("name", name.to_string()));
745 }
746 if let Some(comment) = comment {
747 extra.push(("comment", comment.to_string()));
748 }
749 if let Some(public) = public {
750 extra.push(("public", public.to_string()));
751 }
752 let borrowed: Vec<(&str, &str)> = extra.iter().map(|(k, v)| (*k, v.as_str())).collect();
753 self.get_with_params("updatePlaylist", &borrowed)?;
754 Ok(())
755 }
756
757 pub fn delete_playlist(&self, id: &str) -> Result<(), SubsonicError> {
758 self.get_with_params("deletePlaylist", &[("id", id)])?;
759 Ok(())
760 }
761
762 pub fn koan_users(&self) -> Result<Vec<KoanUser>, SubsonicError> {
765 Ok(self
766 .get("koanUsers")?
767 .users
768 .map(|u| u.user)
769 .unwrap_or_default())
770 }
771
772 pub fn koan_create_user(
774 &self,
775 username: &str,
776 role: &str,
777 ) -> Result<KoanInvite, SubsonicError> {
778 self.get_with_params("koanCreateUser", &[("username", username), ("role", role)])?
779 .invite
780 .ok_or(SubsonicError::BadResponse)
781 }
782
783 pub fn koan_invite(&self, username: &str, reset: bool) -> Result<KoanInvite, SubsonicError> {
786 let reset = if reset { "true" } else { "false" };
787 self.get_with_params("koanInvite", &[("username", username), ("reset", reset)])?
788 .invite
789 .ok_or(SubsonicError::BadResponse)
790 }
791
792 pub fn koan_revoke_own_key(&self) -> Result<(), SubsonicError> {
794 self.get("koanRevokeKey")?;
795 Ok(())
796 }
797
798 pub fn koan_pair_info(&self, pair: &str) -> Result<KoanPair, SubsonicError> {
801 self.get_with_params("koanPairInfo", &[("pair", pair)])?
802 .pair
803 .ok_or(SubsonicError::BadResponse)
804 }
805
806 pub fn koan_pair_approve(&self, pair: &str, decline: bool) -> Result<String, SubsonicError> {
809 let decline = if decline { "true" } else { "false" };
810 self.get_with_params("koanPairApprove", &[("pair", pair), ("decline", decline)])?
811 .pair
812 .map(|p| p.device)
813 .ok_or(SubsonicError::BadResponse)
814 }
815
816 pub fn koan_set_user_password(
819 &self,
820 username: &str,
821 password: &str,
822 ) -> Result<(), SubsonicError> {
823 self.post_with_params(
824 "koanSetUserPassword",
825 &[("username", username), ("password", password)],
826 )?;
827 Ok(())
828 }
829
830 pub fn koan_change_own_password(
834 &self,
835 current: &str,
836 password: &str,
837 device: &str,
838 ) -> Result<KoanJoined, SubsonicError> {
839 self.post_with_params(
840 "koanSetUserPassword",
841 &[
842 ("current", current),
843 ("password", password),
844 ("name", device),
845 ],
846 )?
847 .join
848 .ok_or(SubsonicError::BadResponse)
849 }
850
851 pub fn koan_mcp(&self) -> Result<KoanMcp, SubsonicError> {
854 self.get("koanMcp")?.mcp.ok_or(SubsonicError::BadResponse)
855 }
856
857 pub fn koan_api_keys(&self) -> Result<Vec<KoanApiKey>, SubsonicError> {
860 Ok(self
861 .get("koanApiKeys")?
862 .api_keys
863 .map(|k| k.api_key)
864 .unwrap_or_default())
865 }
866
867 pub fn koan_create_api_key(&self, name: &str) -> Result<KoanApiKey, SubsonicError> {
869 self.post_with_params("koanCreateApiKey", &[("name", name)])?
870 .api_key
871 .ok_or(SubsonicError::BadResponse)
872 }
873
874 pub fn koan_revoke_api_key(&self, id: i64) -> Result<(), SubsonicError> {
875 self.post_with_params("koanRevokeApiKey", &[("id", &id.to_string())])?;
876 Ok(())
877 }
878
879 pub fn koan_app_passwords(&self) -> Result<Vec<KoanAppPassword>, SubsonicError> {
882 Ok(self
883 .get("koanAppPasswords")?
884 .app_passwords
885 .map(|p| p.app_password)
886 .unwrap_or_default())
887 }
888
889 pub fn koan_create_app_password(&self, name: &str) -> Result<KoanAppPassword, SubsonicError> {
892 self.post_with_params("koanCreateAppPassword", &[("name", name)])?
893 .app_password
894 .ok_or(SubsonicError::BadResponse)
895 }
896
897 pub fn koan_revoke_app_password(&self, id: i64) -> Result<(), SubsonicError> {
898 self.post_with_params("koanRevokeAppPassword", &[("id", &id.to_string())])?;
899 Ok(())
900 }
901
902 pub fn koan_set_user_role(&self, username: &str, role: &str) -> Result<(), SubsonicError> {
903 self.get_with_params("koanSetUserRole", &[("username", username), ("role", role)])?;
904 Ok(())
905 }
906
907 pub fn koan_delete_user(&self, username: &str) -> Result<(), SubsonicError> {
908 self.get_with_params("koanDeleteUser", &[("username", username)])?;
909 Ok(())
910 }
911
912 pub fn koan_command(
918 &self,
919 to: &str,
920 command: &str,
921 ack: Option<u64>,
922 ) -> Result<Option<crate::remote::acks::AckOutcome>, SubsonicError> {
923 let ack = ack.map(|a| a.to_string());
924 let mut params = vec![("to", to), ("command", command)];
925 if let Some(ack) = &ack {
926 params.push(("ack", ack));
927 }
928 Ok(self.get_with_params("koanCommand", ¶ms)?.koan_command)
929 }
930
931 pub fn koan_history(
936 &self,
937 since: crate::db::queries::HistoryCursor,
938 count: u32,
939 ) -> Result<KoanHistoryPage, SubsonicError> {
940 self.get_with_params(
941 "koanHistory",
942 &[("since", &since.to_string()), ("count", &count.to_string())],
943 )?
944 .koan_history
945 .ok_or(SubsonicError::BadResponse)
946 }
947
948 pub fn koan_forget_plays(&self, plays: &[(&str, i64)]) -> Result<(), SubsonicError> {
951 let mut pairs = Vec::new();
952 for (id, at) in plays {
953 pairs.push(("id", (*id).to_owned()));
954 pairs.push(("time", at.to_string()));
955 }
956 self.get_with_pairs("koanForgetPlays", &pairs)?;
957 Ok(())
958 }
959
960 pub fn koan_forget_plays_through(&self, at_ms: i64) -> Result<(), SubsonicError> {
962 self.get_with_params("koanForgetPlays", &[("through", &at_ms.to_string())])?;
963 Ok(())
964 }
965
966 pub fn koan_scrobbling(&self) -> Result<KoanScrobbling, SubsonicError> {
970 self.get("koanScrobbling")?
971 .koan_scrobbling
972 .ok_or(SubsonicError::BadResponse)
973 }
974
975 pub fn koan_scrobbling_connect(&self, token: &str) -> Result<KoanScrobbling, SubsonicError> {
978 self.post_with_params("koanScrobblingConnect", &[("token", token)])?
979 .koan_scrobbling
980 .ok_or(SubsonicError::BadResponse)
981 }
982
983 pub fn koan_scrobbling_disconnect(&self) -> Result<KoanScrobbling, SubsonicError> {
984 self.get("koanScrobblingDisconnect")?
985 .koan_scrobbling
986 .ok_or(SubsonicError::BadResponse)
987 }
988
989 pub fn koan_dsp_profiles(&self, since: i64) -> Result<KoanDspProfiles, SubsonicError> {
993 self.get_with_params("koanDspProfiles", &[("since", &since.to_string())])?
994 .koan_dsp_profiles
995 .ok_or(SubsonicError::BadResponse)
996 }
997
998 pub fn koan_dsp_profile_save(
1000 &self,
1001 uid: &str,
1002 edited_at: i64,
1003 doc: &str,
1004 ) -> Result<KoanDspSaved, SubsonicError> {
1005 self.post_with_params(
1006 "koanDspProfileSave",
1007 &[
1008 ("uid", uid),
1009 ("editedAt", &edited_at.to_string()),
1010 ("doc", doc),
1011 ],
1012 )?
1013 .koan_dsp_saved
1014 .ok_or(SubsonicError::BadResponse)
1015 }
1016
1017 pub fn koan_dsp_profile_delete(
1018 &self,
1019 uid: &str,
1020 edited_at: i64,
1021 ) -> Result<KoanDspSaved, SubsonicError> {
1022 self.post_with_params(
1023 "koanDspProfileDelete",
1024 &[("uid", uid), ("editedAt", &edited_at.to_string())],
1025 )?
1026 .koan_dsp_saved
1027 .ok_or(SubsonicError::BadResponse)
1028 }
1029
1030 pub fn koan_dsp_deleted(&self) -> Result<Vec<KoanDspDeletedProfile>, SubsonicError> {
1033 Ok(self
1034 .get("koanDspDeleted")?
1035 .koan_dsp_deleted
1036 .map(|d| d.profile)
1037 .unwrap_or_default())
1038 }
1039
1040 pub fn koan_dsp_restore(&self, uid: &str) -> Result<KoanDspSaved, SubsonicError> {
1042 self.post_with_params("koanDspRestore", &[("uid", uid)])?
1043 .koan_dsp_saved
1044 .ok_or(SubsonicError::BadResponse)
1045 }
1046
1047 pub fn koan_dsp_dismiss(&self, output: &str) -> Result<(), SubsonicError> {
1049 self.post_with_params("koanDspDismiss", &[("output", output)])?;
1050 Ok(())
1051 }
1052
1053 pub fn koan_dsp_file(&self, sha256: &str) -> Result<Vec<u8>, SubsonicError> {
1055 let url = format!("{}/rest/koanDspFile", self.auth.base_url);
1056 let mut params = self.auth_params()?;
1057 params.insert("sha256".into(), sha256.into());
1058 let resp = self
1059 .http
1060 .get(&url)
1061 .query(¶ms)
1062 .timeout(DSP_FILE_TIMEOUT)
1063 .send()?;
1064 let binary = resp
1065 .headers()
1066 .get(reqwest::header::CONTENT_TYPE)
1067 .is_some_and(|t| t.as_bytes().starts_with(b"application/octet-stream"));
1068 if !binary {
1069 let resp: SubsonicResponseWrapper = resp.json()?;
1070 resp.subsonic_response.ok()?;
1071 return Err(SubsonicError::BadResponse);
1072 }
1073 Ok(resp.bytes()?.to_vec())
1074 }
1075
1076 pub fn koan_dsp_upload(&self, sha256: &str, data: Vec<u8>) -> Result<(), SubsonicError> {
1078 let url = format!("{}/rest/koanDspFile", self.auth.base_url);
1079 let mut params = self.auth_params()?;
1080 params.insert("sha256".into(), sha256.into());
1081 let resp: SubsonicResponseWrapper = self
1082 .http
1083 .post(&url)
1084 .query(¶ms)
1085 .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
1086 .body(data)
1087 .timeout(DSP_FILE_TIMEOUT)
1088 .send()?
1089 .json()?;
1090 resp.subsonic_response.ok()?;
1091 Ok(())
1092 }
1093
1094 pub fn get_play_queue(&self, by_index: bool) -> Result<Option<SavedPlayQueue>, SubsonicError> {
1097 let resp = self.get(if by_index {
1098 "getPlayQueueByIndex"
1099 } else {
1100 "getPlayQueue"
1101 })?;
1102 Ok(resp.play_queue_by_index.or(resp.play_queue))
1103 }
1104
1105 #[allow(clippy::too_many_arguments)]
1113 pub fn save_play_queue(
1114 &self,
1115 song_ids: &[String],
1116 current: Option<usize>,
1117 position_ms: u64,
1118 by_index: bool,
1119 form: bool,
1120 client: &str,
1121 ) -> Result<(), SubsonicError> {
1122 let endpoint = if by_index {
1123 "savePlayQueueByIndex"
1124 } else {
1125 "savePlayQueue"
1126 };
1127 let url = format!("{}/rest/{endpoint}", self.auth.base_url);
1128 let mut params = self.auth_params()?;
1129 params.insert("c".into(), client.to_string());
1130 let mut form_pairs: Vec<(String, String)> = params.into_iter().collect();
1131 form_pairs.extend(song_ids.iter().map(|id| ("id".to_string(), id.clone())));
1132 if !song_ids.is_empty() {
1135 let at = current.filter(|at| *at < song_ids.len()).unwrap_or(0);
1136 form_pairs.push(if by_index {
1137 ("currentIndex".into(), at.to_string())
1138 } else {
1139 ("current".into(), song_ids[at].clone())
1140 });
1141 form_pairs.push(("position".into(), position_ms.to_string()));
1142 }
1143 let request = if form {
1144 let body = url::form_urlencoded::Serializer::new(String::new())
1145 .extend_pairs(&form_pairs)
1146 .finish();
1147 self.http
1148 .post(&url)
1149 .header(
1150 reqwest::header::CONTENT_TYPE,
1151 "application/x-www-form-urlencoded",
1152 )
1153 .body(body)
1154 } else {
1155 self.http.get(&url).query(&form_pairs)
1156 };
1157 let resp: SubsonicResponseWrapper = request.send()?.json()?;
1158 resp.subsonic_response.ok().map(|_| ())
1159 }
1160
1161 pub fn auth(&self) -> &SubsonicAuth {
1162 &self.auth
1163 }
1164
1165 pub fn base_url(&self) -> &str {
1167 &self.auth.base_url
1168 }
1169}
1170
1171#[derive(Debug, Deserialize)]
1174struct SubsonicResponseWrapper {
1175 #[serde(rename = "subsonic-response")]
1176 subsonic_response: SubsonicResponse,
1177}
1178
1179#[derive(Debug, Deserialize)]
1180#[serde(rename_all = "camelCase")]
1181struct SubsonicResponse {
1182 status: String,
1183 #[serde(rename = "type")]
1186 server_type: Option<String>,
1187 server_version: Option<String>,
1188 #[serde(default)]
1189 open_subsonic: bool,
1190 open_subsonic_extensions: Option<Vec<SubsonicExtension>>,
1191 error: Option<SubsonicApiError>,
1192 artists: Option<SubsonicArtists>,
1193 album: Option<SubsonicAlbumFull>,
1194 album_list2: Option<SubsonicAlbumList>,
1195 search_result3: Option<SubsonicSearchResult>,
1196 starred2: Option<SubsonicStarred>,
1197 shares: Option<SubsonicShares>,
1198 similar_songs2: Option<SubsonicSimilarSongs>,
1199 playlists: Option<SubsonicPlaylists>,
1200 playlist: Option<SubsonicPlaylistFull>,
1201 scan_status: Option<SubsonicScanStatus>,
1202 indexes: Option<SubsonicIndexes>,
1203 users: Option<KoanUsers>,
1204 api_keys: Option<KoanApiKeys>,
1205 api_key: Option<KoanApiKey>,
1206 app_passwords: Option<KoanAppPasswords>,
1207 app_password: Option<KoanAppPassword>,
1208 mcp: Option<KoanMcp>,
1209 invite: Option<KoanInvite>,
1210 join: Option<KoanJoined>,
1211 pair: Option<KoanPair>,
1212 koan_history: Option<KoanHistoryPage>,
1213 play_queue: Option<SavedPlayQueue>,
1214 play_queue_by_index: Option<SavedPlayQueue>,
1215 koan_command: Option<crate::remote::acks::AckOutcome>,
1216 koan_scrobbling: Option<KoanScrobbling>,
1217 koan_dsp_profiles: Option<KoanDspProfiles>,
1218 koan_dsp_saved: Option<KoanDspSaved>,
1219 koan_dsp_deleted: Option<KoanDspDeleted>,
1220}
1221
1222#[derive(Debug, Clone, Default, Deserialize)]
1225#[serde(rename_all = "camelCase")]
1226pub struct SavedPlayQueue {
1227 #[serde(default)]
1228 pub entry: Vec<SubsonicSong>,
1229 pub current: Option<String>,
1230 pub current_index: Option<usize>,
1231 #[serde(default)]
1232 pub position: u64,
1233 #[serde(default)]
1234 pub changed_by: String,
1235 pub changed: Option<String>,
1236}
1237
1238impl SavedPlayQueue {
1239 pub fn current_at(&self) -> Option<usize> {
1242 self.current_index
1243 .filter(|at| *at < self.entry.len())
1244 .or_else(|| {
1245 let current = self.current.as_deref()?;
1246 self.entry.iter().position(|e| e.id == current)
1247 })
1248 }
1249}
1250
1251const DSP_FILE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
1254
1255#[derive(Debug, Clone, Default, Deserialize)]
1257pub struct KoanDspProfiles {
1258 pub cursor: i64,
1260 #[serde(default)]
1261 pub profile: Vec<KoanDspProfile>,
1262 #[serde(default)]
1264 pub dismissed: Vec<KoanDspDismissed>,
1265}
1266
1267#[derive(Debug, Clone, Deserialize)]
1268#[serde(rename_all = "camelCase")]
1269pub struct KoanDspProfile {
1270 pub uid: String,
1271 pub rev: i64,
1272 pub edited_at: i64,
1273 pub doc: Option<String>,
1275}
1276
1277#[derive(Debug, Clone, Deserialize)]
1278pub struct KoanDspDismissed {
1279 pub output: String,
1280}
1281
1282#[derive(Debug, Clone, Default, Deserialize)]
1283struct KoanDspDeleted {
1284 #[serde(default)]
1285 profile: Vec<KoanDspDeletedProfile>,
1286}
1287
1288#[derive(Debug, Clone, Deserialize)]
1291#[serde(rename_all = "camelCase")]
1292pub struct KoanDspDeletedProfile {
1293 pub uid: String,
1294 pub name: String,
1295 pub deleted_at: i64,
1296 pub expires_at: i64,
1297}
1298
1299#[derive(Debug, Clone, Default, Deserialize)]
1301pub struct KoanDspSaved {
1302 pub rev: i64,
1303 pub stored: bool,
1305 #[serde(default)]
1307 pub missing: Vec<KoanDspMissing>,
1308}
1309
1310#[derive(Debug, Clone, Deserialize)]
1311pub struct KoanDspMissing {
1312 pub sha256: String,
1313}
1314
1315#[derive(Debug, Clone, Default, Deserialize)]
1318pub struct KoanScrobbling {
1319 #[serde(default)]
1320 pub service: Vec<KoanScrobbleService>,
1321}
1322
1323#[derive(Debug, Clone, Deserialize)]
1324pub struct KoanScrobbleService {
1325 pub name: String,
1327 pub account: String,
1329 pub connected: i64,
1331 #[serde(default)]
1333 pub pending: i64,
1334 pub error: Option<String>,
1336}
1337
1338#[derive(Debug, Clone, Default, Deserialize)]
1340pub struct KoanHistoryPage {
1341 pub cursor: String,
1343 #[serde(default)]
1344 pub more: bool,
1345 #[serde(default)]
1346 pub play: Vec<KoanPlay>,
1347 #[serde(default)]
1348 pub forgotten: Vec<KoanForgotten>,
1349}
1350
1351#[derive(Debug, Clone, Deserialize)]
1352#[serde(rename_all = "camelCase")]
1353pub struct KoanPlay {
1354 pub id: String,
1356 #[serde(default)]
1358 pub seq: Option<i64>,
1359 pub played: i64,
1361 pub listened_ms: Option<i64>,
1362}
1363
1364#[derive(Debug, Clone, Deserialize)]
1366pub struct KoanForgotten {
1367 pub id: Option<String>,
1368 pub played: i64,
1369}
1370
1371#[derive(Debug, Clone, Deserialize)]
1375pub struct KoanPair {
1376 pub device: String,
1377 #[serde(default)]
1378 pub from: String,
1379 #[serde(default)]
1380 pub local: bool,
1381}
1382
1383#[derive(Debug, Clone, Deserialize)]
1385pub struct KoanMcp {
1386 pub url: String,
1387 pub connect: String,
1388}
1389
1390#[derive(Debug, Deserialize)]
1391#[serde(rename_all = "camelCase")]
1392struct KoanApiKeys {
1393 #[serde(default)]
1394 api_key: Vec<KoanApiKey>,
1395}
1396
1397#[derive(Debug, Clone, Deserialize)]
1400#[serde(rename_all = "camelCase")]
1401pub struct KoanApiKey {
1402 pub id: i64,
1403 pub name: String,
1404 pub created: Option<String>,
1406 pub last_used: Option<String>,
1407 #[serde(default)]
1409 pub current: bool,
1410 pub key: Option<String>,
1411}
1412
1413#[derive(Debug, Deserialize)]
1414#[serde(rename_all = "camelCase")]
1415struct KoanAppPasswords {
1416 #[serde(default)]
1417 app_password: Vec<KoanAppPassword>,
1418}
1419
1420#[derive(Debug, Clone, Deserialize)]
1423#[serde(rename_all = "camelCase")]
1424pub struct KoanAppPassword {
1425 pub id: i64,
1426 pub name: String,
1427 pub created: Option<String>,
1429 pub last_used: Option<String>,
1430 pub password: Option<String>,
1431}
1432
1433#[derive(Debug, Deserialize)]
1434struct KoanUsers {
1435 #[serde(default)]
1436 user: Vec<KoanUser>,
1437}
1438
1439#[derive(Debug, Clone, Deserialize)]
1441pub struct KoanUser {
1442 pub username: String,
1443 pub role: String,
1444}
1445
1446#[derive(Debug, Clone, Deserialize)]
1450pub struct KoanInvite {
1451 pub username: String,
1452 pub token: String,
1453 pub password: Option<String>,
1454}
1455
1456#[derive(Debug, Clone, Deserialize)]
1459#[serde(rename_all = "camelCase")]
1460pub struct KoanJoined {
1461 pub username: String,
1462 pub api_key: String,
1463}
1464
1465pub fn redeem_invite(
1468 base_url: &str,
1469 token: &str,
1470 device: &str,
1471) -> Result<KoanJoined, SubsonicError> {
1472 let url = format!("{}/rest/koanJoin", base_url.trim_end_matches('/'));
1473 let resp: SubsonicResponseWrapper = download::api_client()?
1474 .get(&url)
1475 .query(&[
1476 ("invite", token),
1477 ("name", device),
1478 ("v", API_VERSION),
1479 ("c", CLIENT_NAME),
1480 ("f", "json"),
1481 ])
1482 .send()?
1483 .json()?;
1484 resp.subsonic_response
1485 .ok()?
1486 .join
1487 .ok_or(SubsonicError::BadResponse)
1488}
1489
1490pub fn offers_unsigned(base_url: &str, extension: &str) -> Result<bool, SubsonicError> {
1494 let url = format!(
1495 "{}/rest/getOpenSubsonicExtensions",
1496 base_url.trim_end_matches('/')
1497 );
1498 let resp: SubsonicResponseWrapper = download::api_client()?
1499 .get(&url)
1500 .query(&[("v", API_VERSION), ("c", CLIENT_NAME), ("f", "json")])
1501 .send()?
1502 .json()?;
1503 Ok(resp.subsonic_response.ok()?.has_extension(extension))
1504}
1505
1506pub fn koan_sign_in(
1511 base_url: &str,
1512 username: &str,
1513 password: &str,
1514 device: &str,
1515) -> Result<KoanJoined, SubsonicError> {
1516 let url = format!("{}/rest/koanSignIn", base_url.trim_end_matches('/'));
1517 let hex: String = password.bytes().map(|b| format!("{b:02x}")).collect();
1518 let p = format!("enc:{hex}");
1519 let resp: SubsonicResponseWrapper = download::api_client()?
1520 .get(&url)
1521 .query(&[
1522 ("u", username),
1523 ("p", &p),
1524 ("name", device),
1525 ("v", API_VERSION),
1526 ("c", CLIENT_NAME),
1527 ("f", "json"),
1528 ])
1529 .send()?
1530 .json()?;
1531 resp.subsonic_response
1532 .ok()?
1533 .join
1534 .ok_or(SubsonicError::BadResponse)
1535}
1536
1537impl SubsonicResponse {
1538 fn ok(self) -> Result<Self, SubsonicError> {
1539 if self.status == "ok" {
1540 return Ok(self);
1541 }
1542 Err(self
1543 .error
1544 .map_or(SubsonicError::BadResponse, |err| SubsonicError::Api {
1545 code: err.code,
1546 message: err.message,
1547 }))
1548 }
1549
1550 fn has_extension(&self, name: &str) -> bool {
1551 self.open_subsonic_extensions
1552 .iter()
1553 .flatten()
1554 .any(|ext| ext.name == name)
1555 }
1556}
1557
1558#[derive(Debug, Deserialize)]
1559struct SubsonicExtension {
1560 name: String,
1561 #[serde(default)]
1562 versions: Vec<i64>,
1563}
1564
1565#[derive(Debug, Deserialize)]
1566#[serde(rename_all = "camelCase")]
1567struct SubsonicIndexes {
1568 last_modified: Option<i64>,
1569}
1570
1571#[derive(Debug, Deserialize)]
1572struct SubsonicScanStatus {
1573 count: Option<u64>,
1574}
1575
1576#[derive(Debug, Deserialize)]
1577struct SubsonicApiError {
1578 code: i32,
1579 message: String,
1580}
1581
1582#[derive(Debug, Deserialize)]
1583struct SubsonicArtists {
1584 index: Vec<SubsonicArtistIndex>,
1585}
1586
1587#[derive(Debug, Deserialize)]
1588struct SubsonicArtistIndex {
1589 artist: Vec<SubsonicArtist>,
1590}
1591
1592#[derive(Debug, Clone, Deserialize)]
1593#[serde(rename_all = "camelCase")]
1594pub struct SubsonicArtist {
1595 pub id: String,
1596 pub name: String,
1597 pub album_count: Option<i32>,
1598 #[serde(default, deserialize_with = "non_empty")]
1601 pub music_brainz_id: Option<String>,
1602 #[serde(default, deserialize_with = "non_empty")]
1603 pub sort_name: Option<String>,
1604}
1605
1606#[derive(Debug, Clone, Deserialize)]
1607#[serde(rename_all = "camelCase")]
1608pub struct SubsonicAlbum {
1609 pub id: String,
1610 pub name: String,
1611 pub artist: Option<String>,
1612 pub artist_id: Option<String>,
1613 pub song_count: Option<i32>,
1614 #[serde(default)]
1616 pub duration: Option<i64>,
1617 pub year: Option<i32>,
1618 #[serde(default, deserialize_with = "non_empty")]
1619 pub genre: Option<String>,
1620 pub created: Option<String>,
1621 #[serde(default, deserialize_with = "non_empty")]
1624 pub music_brainz_id: Option<String>,
1625 #[serde(default, deserialize_with = "non_empty")]
1626 pub sort_name: Option<String>,
1627 #[serde(default)]
1628 pub record_labels: Vec<SubsonicName>,
1629}
1630
1631#[derive(Debug, Clone, Deserialize)]
1634pub struct SubsonicName {
1635 pub name: String,
1636}
1637
1638#[derive(Debug, Clone, Deserialize)]
1639#[serde(rename_all = "camelCase")]
1640pub struct SubsonicAlbumFull {
1641 pub id: String,
1642 pub name: String,
1643 pub artist: Option<String>,
1644 pub artist_id: Option<String>,
1645 pub year: Option<i32>,
1646 #[serde(default, deserialize_with = "non_empty")]
1647 pub genre: Option<String>,
1648 pub song_count: Option<i32>,
1649 pub created: Option<String>,
1650 #[serde(default, deserialize_with = "non_empty")]
1651 pub music_brainz_id: Option<String>,
1652 #[serde(default, deserialize_with = "non_empty")]
1653 pub sort_name: Option<String>,
1654 #[serde(default)]
1655 pub record_labels: Vec<SubsonicName>,
1656 #[serde(default)]
1657 pub song: Vec<SubsonicSong>,
1658}
1659
1660#[derive(Debug, Clone, Deserialize)]
1661#[serde(rename_all = "camelCase")]
1662pub struct SubsonicSong {
1663 pub id: String,
1664 pub title: String,
1665 pub album: Option<String>,
1666 pub artist: Option<String>,
1667 pub track: Option<i32>,
1668 pub disc_number: Option<i32>,
1669 pub year: Option<i32>,
1670 #[serde(default, deserialize_with = "non_empty")]
1671 pub genre: Option<String>,
1672 pub duration: Option<i64>,
1673 pub bit_rate: Option<i32>,
1674 pub suffix: Option<String>,
1675 pub content_type: Option<String>,
1676 #[serde(default, deserialize_with = "non_empty")]
1678 pub codec: Option<String>,
1679 pub album_id: Option<String>,
1680 pub artist_id: Option<String>,
1681 pub sampling_rate: Option<i32>,
1684 pub bit_depth: Option<i32>,
1685 pub channel_count: Option<i32>,
1686 #[serde(default, deserialize_with = "non_empty")]
1687 pub music_brainz_id: Option<String>,
1688}
1689
1690#[derive(Debug, Deserialize)]
1691struct SubsonicAlbumList {
1692 #[serde(default)]
1693 album: Vec<SubsonicAlbum>,
1694}
1695
1696#[derive(Debug, Default, Deserialize)]
1697pub struct SubsonicSearchResult {
1698 #[serde(default)]
1699 pub artist: Vec<SubsonicArtist>,
1700 #[serde(default)]
1701 pub album: Vec<SubsonicAlbum>,
1702 #[serde(default)]
1703 pub song: Vec<SubsonicSong>,
1704}
1705
1706#[derive(Debug, Default, Deserialize)]
1707pub struct SubsonicStarred {
1708 #[serde(default)]
1709 pub song: Vec<SubsonicSong>,
1710 #[serde(default)]
1711 pub album: Vec<SubsonicAlbum>,
1712 #[serde(default)]
1713 pub artist: Vec<SubsonicArtist>,
1714}
1715
1716#[derive(Debug, Deserialize)]
1717pub struct SubsonicSimilarSongs {
1718 pub song: Option<Vec<SubsonicSong>>,
1719}
1720
1721#[derive(Debug, Default, Deserialize)]
1722struct SubsonicPlaylists {
1723 #[serde(default)]
1724 playlist: Vec<SubsonicPlaylist>,
1725}
1726
1727#[derive(Debug, Clone, Deserialize)]
1729#[serde(rename_all = "camelCase")]
1730pub struct SubsonicPlaylist {
1731 pub id: String,
1732 pub name: String,
1733 pub comment: Option<String>,
1734 pub owner: Option<String>,
1735 #[serde(default)]
1736 pub public: bool,
1737 pub song_count: Option<i64>,
1738 pub duration: Option<i64>,
1739 pub created: Option<String>,
1740 pub changed: Option<String>,
1741 #[serde(default)]
1744 pub readonly: bool,
1745}
1746
1747#[derive(Debug, Clone, Deserialize)]
1748#[serde(rename_all = "camelCase")]
1749pub struct SubsonicPlaylistFull {
1750 #[serde(flatten)]
1751 pub playlist: SubsonicPlaylist,
1752 #[serde(default)]
1753 pub entry: Vec<SubsonicSong>,
1754}
1755
1756#[derive(Debug, Deserialize)]
1757struct SubsonicShares {
1758 #[serde(default)]
1759 share: Vec<SubsonicShare>,
1760}
1761
1762#[derive(Debug, Clone, Deserialize)]
1763#[serde(rename_all = "camelCase")]
1764pub struct SubsonicShare {
1765 pub id: String,
1766 pub url: Option<String>,
1767 pub description: Option<String>,
1768 pub username: Option<String>,
1769 pub created: Option<String>,
1770 pub expires: Option<String>,
1771 pub visit_count: Option<i64>,
1772}
1773
1774fn non_empty<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
1781 Ok(Option::<String>::deserialize(d)?.filter(|s| !s.is_empty()))
1782}
1783
1784fn random_salt() -> Result<String, getrandom::Error> {
1791 let mut buf = [0u8; 12];
1792 getrandom::fill(&mut buf)?;
1793 Ok(buf.iter().map(|b| format!("{:02x}", b)).collect())
1794}
1795
1796#[cfg(test)]
1797mod tests {
1798
1799 #[test]
1800 fn a_saved_play_queue_reads_by_index_or_by_id() {
1801 let parse = |json: &str| {
1802 serde_json::from_str::<SubsonicResponseWrapper>(json)
1803 .unwrap()
1804 .subsonic_response
1805 };
1806 let entries = r#"[{"id":"a","title":"A"},{"id":"b","title":"B"},{"id":"a","title":"A"}]"#;
1807 let by_index = parse(&format!(
1808 r#"{{"subsonic-response":{{"status":"ok","playQueueByIndex":{{"entry":{entries},"currentIndex":2,"position":1500,"changedBy":"koan d1"}}}}}}"#
1809 ));
1810 let q = by_index.play_queue_by_index.unwrap();
1811 assert_eq!(
1812 (q.current_at(), q.position, q.changed_by.as_str()),
1813 (Some(2), 1500, "koan d1")
1814 );
1815
1816 let by_id = parse(&format!(
1818 r#"{{"subsonic-response":{{"status":"ok","playQueue":{{"entry":{entries},"current":"a"}}}}}}"#
1819 ));
1820 assert_eq!(by_id.play_queue.unwrap().current_at(), Some(0));
1821 let gone = parse(&format!(
1822 r#"{{"subsonic-response":{{"status":"ok","playQueue":{{"entry":{entries},"current":"z"}}}}}}"#
1823 ));
1824 assert_eq!(gone.play_queue.unwrap().current_at(), None);
1825 }
1826
1827 use super::*;
1828
1829 fn response(json: &str) -> SubsonicResponse {
1830 serde_json::from_str::<SubsonicResponseWrapper>(json)
1831 .unwrap()
1832 .subsonic_response
1833 }
1834
1835 #[test]
1836 fn http_errors_leave_the_signed_url_out() {
1837 let e = reqwest::blocking::get("http://127.0.0.1:1/rest/stream?u=owner&p=enc:736563726574")
1839 .unwrap_err();
1840 assert!(e.to_string().contains("enc:736563726574"));
1841 let shown = SubsonicError::from(e).to_string();
1842 assert!(!shown.contains("enc:"), "{shown}");
1843 let e = reqwest::blocking::get("http://127.0.0.1:1/rest/stream?p=enc:736563726574")
1844 .unwrap_err();
1845 assert!(!DownloadError::from(e).to_string().contains("enc:"));
1846 }
1847
1848 #[test]
1849 fn playback_report_is_read_from_the_advertised_extensions() {
1850 let navidrome = response(
1851 r#"{"subsonic-response": {
1852 "status": "ok", "version": "1.16.1", "type": "navidrome",
1853 "openSubsonic": true,
1854 "openSubsonicExtensions": [
1855 {"name": "transcodeOffset", "versions": [1]},
1856 {"name": "playbackReport", "versions": [1]}
1857 ]
1858 }}"#,
1859 );
1860 assert!(navidrome.has_extension(PLAYBACK_REPORT_EXTENSION));
1861
1862 let without = response(
1863 r#"{"subsonic-response": {
1864 "status": "ok", "version": "1.16.1",
1865 "openSubsonicExtensions": [{"name": "songLyrics", "versions": [1, 2]}]
1866 }}"#,
1867 );
1868 assert!(!without.has_extension(PLAYBACK_REPORT_EXTENSION));
1869
1870 let plain = response(r#"{"subsonic-response": {"status": "ok", "version": "1.16.1"}}"#);
1871 assert!(!plain.has_extension(PLAYBACK_REPORT_EXTENSION));
1872 }
1873
1874 #[test]
1877 fn test_deserialize_subsonic_song() {
1878 let json = r#"{
1879 "id": "42",
1880 "title": "Space Oddity",
1881 "album": "Space Oddity",
1882 "artist": "David Bowie",
1883 "track": 1,
1884 "discNumber": 1,
1885 "year": 1969,
1886 "genre": "Rock",
1887 "duration": 314,
1888 "bitRate": 320,
1889 "suffix": "mp3",
1890 "contentType": "audio/mpeg",
1891 "albumId": "7",
1892 "artistId": "3"
1893 }"#;
1894
1895 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1896
1897 assert_eq!(song.id, "42");
1898 assert_eq!(song.title, "Space Oddity");
1899 assert_eq!(song.album.as_deref(), Some("Space Oddity"));
1900 assert_eq!(song.artist.as_deref(), Some("David Bowie"));
1901 assert_eq!(song.track, Some(1));
1902 assert_eq!(song.disc_number, Some(1));
1903 assert_eq!(song.year, Some(1969));
1904 assert_eq!(song.genre.as_deref(), Some("Rock"));
1905 assert_eq!(song.duration, Some(314));
1906 assert_eq!(song.bit_rate, Some(320));
1907 assert_eq!(song.suffix.as_deref(), Some("mp3"));
1908 assert_eq!(song.content_type.as_deref(), Some("audio/mpeg"));
1909 assert_eq!(song.album_id.as_deref(), Some("7"));
1910 assert_eq!(song.artist_id.as_deref(), Some("3"));
1911 }
1912
1913 #[test]
1917 fn opensubsonic_quality_fields_are_read() {
1918 let json = r#"{
1919 "id": "000XtGC7jsWEbOjDsZi4Xw",
1920 "title": "Anguish",
1921 "suffix": "flac",
1922 "bitRate": 913,
1923 "samplingRate": 44100,
1924 "bitDepth": 16,
1925 "channelCount": 2
1926 }"#;
1927
1928 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1929
1930 assert_eq!(song.sampling_rate, Some(44100));
1931 assert_eq!(song.bit_depth, Some(16));
1932 assert_eq!(song.channel_count, Some(2));
1933 }
1934
1935 #[test]
1938 fn a_koan_server_names_the_codec() {
1939 let json = r#"{"id": "1", "title": "Seawhite", "suffix": "m4a", "codec": "ALAC"}"#;
1940 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1941 assert_eq!(song.codec.as_deref(), Some("ALAC"));
1942
1943 let json = r#"{"id": "1", "title": "Seawhite", "suffix": "m4a"}"#;
1944 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1945 assert_eq!(song.codec, None);
1946 }
1947
1948 #[test]
1951 fn a_plain_subsonic_song_has_no_quality_figures() {
1952 let json = r#"{"id": "1", "title": "Track", "bitRate": 320}"#;
1953 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1954
1955 assert_eq!(song.sampling_rate, None);
1956 assert_eq!(song.bit_depth, None);
1957 assert_eq!(song.channel_count, None);
1958 }
1959
1960 #[test]
1961 fn test_deserialize_subsonic_song_optional_fields_absent() {
1962 let json = r#"{"id": "99", "title": "Minimal Track"}"#;
1964
1965 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1966
1967 assert_eq!(song.id, "99");
1968 assert_eq!(song.title, "Minimal Track");
1969 assert!(song.album.is_none());
1970 assert!(song.artist.is_none());
1971 assert!(song.track.is_none());
1972 assert!(song.disc_number.is_none());
1973 assert!(song.year.is_none());
1974 assert!(song.duration.is_none());
1975 assert!(song.bit_rate.is_none());
1976 }
1977
1978 #[test]
1981 fn test_deserialize_album_list() {
1982 let json = r#"{
1983 "subsonic-response": {
1984 "status": "ok",
1985 "version": "1.16.1",
1986 "albumList2": {
1987 "album": [
1988 {
1989 "id": "1",
1990 "name": "Abbey Road",
1991 "artist": "The Beatles",
1992 "artistId": "10",
1993 "songCount": 17,
1994 "year": 1969,
1995 "genre": "Rock",
1996 "created": "2020-01-01T00:00:00"
1997 },
1998 {
1999 "id": "2",
2000 "name": "Led Zeppelin IV",
2001 "artist": "Led Zeppelin",
2002 "artistId": "11",
2003 "songCount": 8,
2004 "year": 1971,
2005 "genre": "Hard Rock",
2006 "created": "2020-01-02T00:00:00"
2007 }
2008 ]
2009 }
2010 }
2011 }"#;
2012
2013 let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
2014 let album_list = wrapper
2015 .subsonic_response
2016 .album_list2
2017 .expect("album_list2 should be present");
2018
2019 assert_eq!(album_list.album.len(), 2);
2020
2021 let first = &album_list.album[0];
2022 assert_eq!(first.id, "1");
2023 assert_eq!(first.name, "Abbey Road");
2024 assert_eq!(first.artist.as_deref(), Some("The Beatles"));
2025 assert_eq!(first.artist_id.as_deref(), Some("10"));
2026 assert_eq!(first.song_count, Some(17));
2027 assert_eq!(first.year, Some(1969));
2028
2029 let second = &album_list.album[1];
2030 assert_eq!(second.id, "2");
2031 assert_eq!(second.name, "Led Zeppelin IV");
2032 assert_eq!(second.song_count, Some(8));
2033 }
2034
2035 #[test]
2038 fn test_auth_params_format() {
2039 let client = SubsonicClient::new("http://localhost:4533", "alice", "secret");
2040 let params = client.auth_params().unwrap();
2041
2042 assert!(params.contains_key("u"), "missing 'u' param");
2044 assert!(params.contains_key("t"), "missing 't' param");
2045 assert!(params.contains_key("s"), "missing 's' param");
2046 assert!(params.contains_key("v"), "missing 'v' param");
2047 assert!(params.contains_key("c"), "missing 'c' param");
2048 assert!(params.contains_key("f"), "missing 'f' param");
2049 assert_eq!(params.len(), 6);
2050
2051 assert_eq!(params["u"], "alice");
2052 assert_eq!(params["v"], "1.16.1");
2053 assert_eq!(params["c"], "koan");
2054 assert_eq!(params["f"], "json");
2055 }
2056
2057 #[test]
2058 fn test_auth_params_over_https_send_the_hex_password() {
2059 let client = SubsonicClient::new("https://koan.example", "alice", "hi");
2060 let params = client.auth_params().unwrap();
2061 assert_eq!(params["p"], "enc:6869");
2062 assert!(!params.contains_key("t") && !params.contains_key("s"));
2063 }
2064
2065 #[test]
2066 fn test_auth_params_with_an_api_key_name_no_user() {
2067 let auth = SubsonicAuth::with(
2068 "http://koan.example",
2069 "alice",
2070 Credential::ApiKey("k3y".into()),
2071 );
2072 let params = auth.params().unwrap();
2073 assert_eq!(params.get("apiKey").map(String::as_str), Some("k3y"));
2074 for absent in ["u", "p", "t", "s"] {
2075 assert!(!params.contains_key(absent), "{absent}");
2076 }
2077 }
2078
2079 #[test]
2080 fn test_auth_params_token_is_md5_of_password_plus_salt() {
2081 let client = SubsonicClient::new("http://localhost:4533", "bob", "letmein");
2082 let params = client.auth_params().unwrap();
2083
2084 let salt = ¶ms["s"];
2085 let token = ¶ms["t"];
2086
2087 let expected = format!("{:x}", md5::compute(format!("letmein{}", salt)));
2089 assert_eq!(token, &expected);
2090 }
2091
2092 #[test]
2093 fn test_auth_params_salt_is_different_each_call() {
2094 let client = SubsonicClient::new("http://localhost:4533", "user", "pass");
2095 let params1 = client.auth_params().unwrap();
2096 let params2 = client.auth_params().unwrap();
2097
2098 assert_ne!(params1["s"], params2["s"], "salt should be random per call");
2101 }
2102
2103 #[test]
2106 fn test_stream_url_has_auth() {
2107 let client = SubsonicClient::new("http://myserver:4533", "user", "pass");
2108 let url = client.stream_url("track-123").unwrap();
2109
2110 assert!(url.contains("track-123"), "url must include the track id");
2111 assert!(url.contains("u=user"), "url must include username param");
2112 assert!(url.contains("v=1.16.1"), "url must include api version");
2113 assert!(url.contains("c=koan"), "url must include client name");
2114 assert!(url.contains("f=json"), "url must include format param");
2115 assert!(url.contains("/rest/stream"), "url must target /rest/stream");
2116 assert!(
2117 url.starts_with("http://myserver:4533"),
2118 "url must use the configured base_url"
2119 );
2120 }
2121
2122 #[test]
2123 fn test_stream_url_base_url_trailing_slash_normalised() {
2124 let client_with_slash = SubsonicClient::new("http://myserver:4533/", "u", "p");
2126 let client_no_slash = SubsonicClient::new("http://myserver:4533", "u", "p");
2127
2128 let url_with = client_with_slash.stream_url("1").unwrap();
2129 let url_without = client_no_slash.stream_url("1").unwrap();
2130
2131 assert!(
2133 url_with.contains("/rest/stream"),
2134 "should not have double slash"
2135 );
2136 assert!(!url_with.contains("//rest"), "should not have double slash");
2137 assert_eq!(
2139 url_with.split('?').next(),
2140 url_without.split('?').next(),
2141 "path segment should be identical regardless of trailing slash"
2142 );
2143 }
2144
2145 #[test]
2148 fn test_deserialize_album_full_with_songs() {
2149 let json = r#"{
2150 "id": "5",
2151 "name": "Kind of Blue",
2152 "artist": "Miles Davis",
2153 "artistId": "20",
2154 "year": 1959,
2155 "genre": "Jazz",
2156 "songCount": 5,
2157 "created": "2021-06-01T00:00:00",
2158 "song": [
2159 {"id": "101", "title": "So What"},
2160 {"id": "102", "title": "Freddie Freeloader"},
2161 {"id": "103", "title": "Blue in Green"}
2162 ]
2163 }"#;
2164
2165 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
2166
2167 assert_eq!(album.id, "5");
2168 assert_eq!(album.name, "Kind of Blue");
2169 assert_eq!(album.artist.as_deref(), Some("Miles Davis"));
2170 assert_eq!(album.year, Some(1959));
2171 assert_eq!(album.song.len(), 3);
2172 assert_eq!(album.song[0].title, "So What");
2173 assert_eq!(album.song[2].id, "103");
2174 }
2175
2176 #[test]
2177 fn test_deserialize_album_full_empty_song_list() {
2178 let json = r#"{"id": "9", "name": "No Tracks Yet"}"#;
2180
2181 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
2182
2183 assert_eq!(album.id, "9");
2184 assert!(album.song.is_empty(), "song list should default to empty");
2185 }
2186
2187 #[test]
2190 fn test_deserialize_search_result_mixed() {
2191 let json = r#"{
2192 "artist": [{"id": "1", "name": "Artist One"}],
2193 "album": [{"id": "2", "name": "Album One"}],
2194 "song": [{"id": "3", "title": "Song One"}]
2195 }"#;
2196
2197 let result: SubsonicSearchResult = serde_json::from_str(json).unwrap();
2198
2199 assert_eq!(result.artist.len(), 1);
2200 assert_eq!(result.artist[0].name, "Artist One");
2201 assert_eq!(result.album.len(), 1);
2202 assert_eq!(result.album[0].name, "Album One");
2203 assert_eq!(result.song.len(), 1);
2204 assert_eq!(result.song[0].title, "Song One");
2205 }
2206
2207 #[test]
2208 fn empty_opensubsonic_ids_are_absent() {
2209 let json = r#"{"id": "1", "title": "T", "musicBrainzId": ""}"#;
2210 let song: SubsonicSong = serde_json::from_str(json).unwrap();
2211 assert_eq!(song.music_brainz_id, None);
2212
2213 let json = r#"{"id": "2", "name": "A", "musicBrainzId": "", "sortName": ""}"#;
2214 let album: SubsonicAlbum = serde_json::from_str(json).unwrap();
2215 assert_eq!((album.music_brainz_id, album.sort_name), (None, None));
2216
2217 let json = r#"{"id": "3", "name": "A", "musicBrainzId": "mb-1"}"#;
2218 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
2219 assert_eq!(album.music_brainz_id.as_deref(), Some("mb-1"));
2220 assert_eq!(album.sort_name, None);
2221 }
2222
2223 #[test]
2224 fn deleted_dsp_profiles_read_with_their_times() {
2225 let listed = response(
2226 r#"{"subsonic-response": {"status": "ok", "koanDspDeleted": {"profile": [
2227 {"uid": "u1", "name": "Room", "deletedAt": 1000, "expiresAt": 2592001000}
2228 ]}}}"#,
2229 );
2230 let listed = listed.koan_dsp_deleted.unwrap().profile;
2231 assert_eq!(listed.len(), 1);
2232 assert_eq!(
2233 (listed[0].uid.as_str(), listed[0].name.as_str()),
2234 ("u1", "Room")
2235 );
2236 assert_eq!(
2237 (listed[0].deleted_at, listed[0].expires_at),
2238 (1000, 2_592_001_000)
2239 );
2240 let none = response(r#"{"subsonic-response": {"status": "ok", "koanDspDeleted": {}}}"#);
2241 assert!(none.koan_dsp_deleted.unwrap().profile.is_empty());
2242 }
2243
2244 #[test]
2245 fn app_passwords_read_listed_and_made() {
2246 let listed = response(
2247 r#"{"subsonic-response": {"status": "ok", "appPasswords": {"appPassword": [
2248 {"id": 3, "name": "Arpeggi", "created": "2026-10-08T09:00:00Z"},
2249 {"id": 4, "name": "Tempo", "created": "2026-10-08T10:00:00Z", "lastUsed": "2026-10-08T11:00:00Z"}
2250 ]}}}"#,
2251 );
2252 let listed = listed.app_passwords.unwrap().app_password;
2253 assert_eq!(listed.len(), 2);
2254 assert_eq!((listed[0].id, listed[0].name.as_str()), (3, "Arpeggi"));
2255 assert_eq!(listed[0].last_used, None);
2256 assert_eq!(listed[1].last_used.as_deref(), Some("2026-10-08T11:00:00Z"));
2257 assert!(listed.iter().all(|p| p.password.is_none()));
2258
2259 let none = response(r#"{"subsonic-response": {"status": "ok", "appPasswords": {}}}"#);
2260 assert!(none.app_passwords.unwrap().app_password.is_empty());
2261
2262 let made = response(
2263 r#"{"subsonic-response": {"status": "ok", "appPassword": {"id": 5, "name": "Feishin", "password": "s3cret"}}}"#,
2264 );
2265 let made = made.app_password.unwrap();
2266 assert_eq!((made.id, made.password.as_deref()), (5, Some("s3cret")));
2267 }
2268
2269 #[test]
2270 fn test_deserialize_scan_status_count() {
2271 let json = r#"{"subsonic-response":{"status":"ok","scanStatus":{"scanning":false,"count":49700}}}"#;
2272 let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
2273 let status = wrapper.subsonic_response.scan_status.unwrap();
2274 assert_eq!(status.count, Some(49_700));
2275 }
2276
2277 #[test]
2278 fn test_deserialize_search_result_defaults_to_empty() {
2279 let result: SubsonicSearchResult = serde_json::from_str("{}").unwrap();
2281
2282 assert!(result.artist.is_empty());
2283 assert!(result.album.is_empty());
2284 assert!(result.song.is_empty());
2285 }
2286}