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 {
143 auth: SubsonicAuth,
144 connections: parking_lot::RwLock<Connections>,
145 outage: download::Outage,
147 playback_report: OnceLock<bool>,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum PlaybackReportState {
154 Playing,
155 Paused,
156 Stopped,
157}
158
159impl PlaybackReportState {
160 fn as_str(self) -> &'static str {
161 match self {
162 Self::Playing => "playing",
163 Self::Paused => "paused",
164 Self::Stopped => "stopped",
165 }
166 }
167}
168
169struct Connections {
170 http: reqwest::blocking::Client,
171 downloader: reqwest::blocking::Client,
172}
173
174impl Connections {
175 fn new() -> Self {
176 Self {
177 http: download::api_client().unwrap_or_else(|e| {
178 log::warn!("falling back to default HTTP client: {}", e);
179 reqwest::blocking::Client::new()
180 }),
181 downloader: download::download_client().unwrap_or_else(|e| {
182 log::warn!("falling back to default download client: {}", e);
183 reqwest::blocking::Client::new()
184 }),
185 }
186 }
187}
188
189impl SubsonicClient {
190 pub fn new(base_url: &str, username: &str, password: &str) -> Self {
191 Self::from_auth(SubsonicAuth::new(base_url, username, password))
192 }
193
194 pub fn from_auth(auth: SubsonicAuth) -> Self {
195 Self {
196 auth,
197 connections: parking_lot::RwLock::new(Connections::new()),
198 outage: download::Outage::default(),
199 playback_report: OnceLock::new(),
200 }
201 }
202
203 pub fn outage(&self) -> &download::Outage {
205 &self.outage
206 }
207
208 pub fn renew(&self) {
212 let fresh = Connections::new();
215 let stale = std::mem::replace(&mut *self.connections.write(), fresh);
216 drop(stale);
217 self.outage.retry_now();
218 }
219
220 fn http(&self) -> reqwest::blocking::Client {
221 self.connections.read().http.clone()
222 }
223
224 fn downloader(&self) -> reqwest::blocking::Client {
225 self.connections.read().downloader.clone()
226 }
227
228 fn auth_params(&self) -> Result<HashMap<String, String>, SubsonicError> {
229 self.auth.params()
230 }
231
232 fn get(&self, endpoint: &str) -> Result<SubsonicResponse, SubsonicError> {
234 self.get_with_params(endpoint, &[])
235 }
236
237 fn get_with_params(
238 &self,
239 endpoint: &str,
240 extra: &[(&str, &str)],
241 ) -> Result<SubsonicResponse, SubsonicError> {
242 let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
243 let mut params = self.auth_params()?;
244 for (k, v) in extra {
245 params.insert((*k).to_string(), (*v).to_string());
246 }
247
248 let resp: SubsonicResponseWrapper = self.http().get(&url).query(¶ms).send()?.json()?;
249 resp.subsonic_response.ok()
250 }
251
252 fn post_with_params(
256 &self,
257 endpoint: &str,
258 extra: &[(&str, &str)],
259 ) -> Result<SubsonicResponse, SubsonicError> {
260 let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
261 let mut params = self.auth_params()?;
262 for (k, v) in extra {
263 params.insert((*k).to_string(), (*v).to_string());
264 }
265 let resp: SubsonicResponseWrapper = self.http().post(&url).form(¶ms).send()?.json()?;
266 resp.subsonic_response.ok()
267 }
268
269 fn get_with_pairs(
272 &self,
273 endpoint: &str,
274 pairs: &[(&str, String)],
275 ) -> Result<SubsonicResponse, SubsonicError> {
276 let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
277 let params = self.auth_params()?;
278 let resp: SubsonicResponseWrapper = self
279 .http()
280 .get(&url)
281 .query(¶ms)
282 .query(pairs)
283 .send()?
284 .json()?;
285 resp.subsonic_response.ok()
286 }
287
288 fn reject_error_body(resp: &reqwest::blocking::Response) -> Result<(), SubsonicError> {
295 let is_document = resp
296 .headers()
297 .get(reqwest::header::CONTENT_TYPE)
298 .and_then(|v| v.to_str().ok())
299 .is_some_and(|ct| ct.contains("json") || ct.contains("xml"));
300 if is_document {
301 return Err(SubsonicError::BadResponse);
302 }
303 if !resp.status().is_success() {
304 return Err(SubsonicError::BadResponse);
305 }
306 Ok(())
307 }
308
309 pub fn get_cover_art(&self, id: &str, size: Option<u32>) -> Result<Vec<u8>, SubsonicError> {
315 let url = format!("{}/rest/getCoverArt", self.base_url());
316 let mut params = self.auth_params()?;
317 params.insert("id".into(), id.to_string());
318 if let Some(px) = size {
319 params.insert("size".into(), px.to_string());
320 }
321
322 let resp = self.http().get(&url).query(¶ms).send()?;
323 Self::reject_error_body(&resp)?;
324 Ok(resp.bytes()?.to_vec())
325 }
326
327 pub fn ping(&self) -> Result<(), SubsonicError> {
329 self.get("ping")?;
330 Ok(())
331 }
332
333 pub fn server_type(&self) -> Result<Option<String>, SubsonicError> {
335 Ok(self.get("ping")?.server_type)
336 }
337
338 pub fn profile(&self) -> Result<crate::remote::profile::ServerProfile, SubsonicError> {
342 let ping = self.get("ping")?;
343 let extensions = if ping.open_subsonic {
344 self.get("getOpenSubsonicExtensions")
345 .ok()
346 .and_then(|r| r.open_subsonic_extensions)
347 .unwrap_or_default()
348 .into_iter()
349 .map(|e| (e.name, e.versions))
350 .collect()
351 } else {
352 Vec::new()
353 };
354 Ok(crate::remote::profile::ServerProfile {
355 kind: ping.server_type,
356 version: ping.server_version,
357 open_subsonic: ping.open_subsonic,
358 extensions,
359 })
360 }
361
362 pub fn get_artists(&self) -> Result<Vec<SubsonicArtist>, SubsonicError> {
364 let resp = self.get("getArtists")?;
365 let artists_data = resp.artists.ok_or(SubsonicError::BadResponse)?;
366 let mut all = Vec::new();
367 for index in artists_data.index {
368 all.extend(index.artist);
369 }
370 Ok(all)
371 }
372
373 pub fn get_album(&self, id: &str) -> Result<SubsonicAlbumFull, SubsonicError> {
375 let resp = self.get_with_params("getAlbum", &[("id", id)])?;
376 resp.album.ok_or(SubsonicError::BadResponse)
377 }
378
379 pub fn get_album_list(
381 &self,
382 list_type: &str,
383 size: u32,
384 offset: u32,
385 ) -> Result<Vec<SubsonicAlbum>, SubsonicError> {
386 let size_str = size.to_string();
387 let offset_str = offset.to_string();
388 let resp = self.get_with_params(
389 "getAlbumList2",
390 &[
391 ("type", list_type),
392 ("size", &size_str),
393 ("offset", &offset_str),
394 ],
395 )?;
396 Ok(resp.album_list2.map(|al| al.album).unwrap_or_default())
397 }
398
399 pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
401 self.auth.stream_url(track_id)
402 }
403
404 pub fn stream_url_template(&self, track_id: &str) -> String {
406 format!("{}/rest/stream?id={}", self.auth.base_url, track_id)
407 }
408
409 pub fn download(&self, track_id: &str, dest: &Path) -> Result<(), SubsonicError> {
411 self.fetch_to_file("download", track_id, dest, None, |_, _| {})
412 }
413
414 pub fn download_with_progress(
423 &self,
424 track_id: &str,
425 dest: &Path,
426 cancelled: &dyn Fn() -> bool,
427 on_progress: impl Fn(u64, u64),
428 ) -> Result<(), SubsonicError> {
429 let patience = download::Patience {
430 outage: &self.outage,
431 cancelled,
432 };
433 self.fetch_to_file("download", track_id, dest, Some(patience), on_progress)
434 }
435
436 pub fn stream_to_file(
442 &self,
443 track_id: &str,
444 dest: &Path,
445 on_progress: impl Fn(u64, u64),
446 ) -> Result<(), SubsonicError> {
447 self.fetch_to_file("stream", track_id, dest, None, on_progress)
448 }
449
450 fn fetch_to_file(
451 &self,
452 endpoint: &str,
453 track_id: &str,
454 dest: &Path,
455 patience: Option<download::Patience<'_>>,
456 on_progress: impl Fn(u64, u64),
457 ) -> Result<(), SubsonicError> {
458 let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
459 download::download_with_retries(
460 dest,
461 download::DEFAULT_ATTEMPTS,
462 patience,
463 || {
464 let mut params = self
466 .auth_params()
467 .map_err(|e| download::DownloadError::Request(e.to_string()))?;
468 params.insert("id".into(), track_id.to_string());
469 Ok(self.downloader().get(&url).query(¶ms))
470 },
471 on_progress,
472 )?;
473 Ok(())
474 }
475
476 pub fn all_songs_page(
482 &self,
483 size: u32,
484 offset: u32,
485 ) -> Result<Vec<SubsonicSong>, SubsonicError> {
486 let size = size.to_string();
487 let offset = offset.to_string();
488 let resp = self.get_with_params(
489 "search3",
490 &[
491 ("query", ""),
492 ("artistCount", "0"),
493 ("albumCount", "0"),
494 ("songCount", &size),
495 ("songOffset", &offset),
496 ],
497 )?;
498 Ok(resp.search_result3.map(|r| r.song).unwrap_or_default())
499 }
500
501 pub fn song_count(&self) -> Result<Option<u64>, SubsonicError> {
504 let resp = self.get("getScanStatus")?;
505 Ok(resp.scan_status.and_then(|s| s.count))
506 }
507
508 pub fn library_modified(&self, since: Option<i64>) -> Result<Option<i64>, SubsonicError> {
513 let since = since.map(|v| v.to_string());
514 let params: Vec<(&str, &str)> = since
515 .as_deref()
516 .map(|v| vec![("ifModifiedSince", v)])
517 .unwrap_or_default();
518 let resp = self.get_with_params("getIndexes", ¶ms)?;
519 Ok(resp.indexes.and_then(|i| i.last_modified))
520 }
521
522 pub fn search(&self, query: &str) -> Result<SubsonicSearchResult, SubsonicError> {
524 let resp = self.get_with_params("search3", &[("query", query)])?;
525 Ok(resp.search_result3.unwrap_or_default())
526 }
527
528 pub fn scrobble(&self, track_id: &str, heard_at_ms: u64) -> Result<(), SubsonicError> {
531 let at = heard_at_ms.to_string();
532 self.get_with_params(
533 "scrobble",
534 &[("id", track_id), ("submission", "true"), ("time", &at)],
535 )?;
536 Ok(())
537 }
538
539 pub fn scrobble_many(&self, plays: &[(&str, i64)]) -> Result<(), SubsonicError> {
541 let mut pairs = vec![("submission", "true".to_owned())];
542 for (id, at) in plays {
543 pairs.push(("id", (*id).to_owned()));
544 pairs.push(("time", at.to_string()));
545 }
546 self.get_with_pairs("scrobble", &pairs)?;
547 Ok(())
548 }
549
550 pub fn report_playback(
559 &self,
560 track_id: &str,
561 state: PlaybackReportState,
562 position_ms: u64,
563 ) -> Result<(), SubsonicError> {
564 if self.supports_playback_report() {
565 let position = position_ms.to_string();
566 self.get_with_params(
567 "reportPlayback",
568 &[
569 ("mediaId", track_id),
570 ("mediaType", "song"),
571 ("positionMs", &position),
572 ("state", state.as_str()),
573 ("ignoreScrobble", "true"),
574 ],
575 )?;
576 return Ok(());
577 }
578 if state == PlaybackReportState::Playing {
579 let position = (position_ms / 1000).to_string();
580 self.get_with_params(
581 "scrobble",
582 &[
583 ("id", track_id),
584 ("submission", "false"),
585 ("position", &position),
586 ],
587 )?;
588 }
589 Ok(())
590 }
591
592 fn supports_playback_report(&self) -> bool {
596 if let Some(&known) = self.playback_report.get() {
597 return known;
598 }
599 let supported = match self.get("getOpenSubsonicExtensions") {
600 Ok(resp) => resp.has_extension(PLAYBACK_REPORT_EXTENSION),
601 Err(SubsonicError::Http(e)) if e.is_connect() || e.is_timeout() => return false,
602 Err(_) => false,
603 };
604 *self.playback_report.get_or_init(|| supported)
605 }
606
607 pub fn star(&self, track_id: &str) -> Result<(), SubsonicError> {
609 self.get_with_params("star", &[("id", track_id)])?;
610 Ok(())
611 }
612
613 pub fn unstar(&self, track_id: &str) -> Result<(), SubsonicError> {
615 self.get_with_params("unstar", &[("id", track_id)])?;
616 Ok(())
617 }
618
619 pub fn get_starred_all(&self) -> Result<SubsonicStarred, SubsonicError> {
624 let resp = self.get("getStarred2")?;
625 Ok(resp.starred2.unwrap_or_default())
626 }
627
628 pub fn star_album(&self, album_id: &str) -> Result<(), SubsonicError> {
631 self.get_with_params("star", &[("albumId", album_id)])?;
632 Ok(())
633 }
634
635 pub fn unstar_album(&self, album_id: &str) -> Result<(), SubsonicError> {
636 self.get_with_params("unstar", &[("albumId", album_id)])?;
637 Ok(())
638 }
639
640 pub fn star_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
641 self.get_with_params("star", &[("artistId", artist_id)])?;
642 Ok(())
643 }
644
645 pub fn unstar_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
646 self.get_with_params("unstar", &[("artistId", artist_id)])?;
647 Ok(())
648 }
649
650 pub fn create_share(
653 &self,
654 ids: &[&str],
655 description: Option<&str>,
656 ) -> Result<SubsonicShare, SubsonicError> {
657 let url = format!("{}/rest/createShare", self.auth.base_url);
658 let mut params = self.auth_params()?;
659 if let Some(desc) = description {
660 params.insert("description".into(), desc.to_string());
661 }
662
663 let mut query: Vec<(String, String)> = params.into_iter().collect();
665 for id in ids {
666 query.push(("id".into(), (*id).to_string()));
667 }
668
669 let resp: SubsonicResponseWrapper = self.http().get(&url).query(&query).send()?.json()?;
670
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
682 inner
683 .shares
684 .and_then(|s| s.share.into_iter().next())
685 .ok_or(SubsonicError::BadResponse)
686 }
687
688 pub fn get_similar_songs(
691 &self,
692 song_id: &str,
693 count: usize,
694 ) -> Result<Vec<SubsonicSong>, SubsonicError> {
695 let count_str = count.to_string();
696 let resp = self.get_with_params(
697 "getSimilarSongs2",
698 &[("id", song_id), ("count", &count_str)],
699 )?;
700 Ok(resp.similar_songs2.and_then(|s| s.song).unwrap_or_default())
701 }
702
703 pub fn get_playlists(&self) -> Result<Vec<SubsonicPlaylist>, SubsonicError> {
707 let resp = self.get("getPlaylists")?;
708 Ok(resp.playlists.map(|p| p.playlist).unwrap_or_default())
709 }
710
711 pub fn get_playlist(&self, id: &str) -> Result<SubsonicPlaylistFull, SubsonicError> {
713 let resp = self.get_with_params("getPlaylist", &[("id", id)])?;
714 resp.playlist.ok_or(SubsonicError::BadResponse)
715 }
716
717 pub fn create_playlist(
726 &self,
727 playlist_id: Option<&str>,
728 name: &str,
729 song_ids: &[String],
730 ) -> Result<Option<SubsonicPlaylistFull>, SubsonicError> {
731 let url = format!("{}/rest/createPlaylist", self.auth.base_url);
732 let mut params = self.auth_params()?;
733 match playlist_id {
734 Some(id) => {
735 params.insert("playlistId".into(), id.to_string());
736 params.insert("name".into(), name.to_string());
739 }
740 None => {
741 params.insert("name".into(), name.to_string());
742 }
743 }
744
745 let mut query: Vec<(String, String)> = params.into_iter().collect();
747 for id in song_ids {
748 query.push(("songId".into(), id.clone()));
749 }
750
751 let resp: SubsonicResponseWrapper = self.http().get(&url).query(&query).send()?.json()?;
752 let inner = resp.subsonic_response;
753 if inner.status != "ok" {
754 if let Some(err) = inner.error {
755 return Err(SubsonicError::Api {
756 code: err.code,
757 message: err.message,
758 });
759 }
760 return Err(SubsonicError::BadResponse);
761 }
762 Ok(inner.playlist)
766 }
767
768 pub fn update_playlist(
770 &self,
771 id: &str,
772 name: Option<&str>,
773 comment: Option<&str>,
774 public: Option<bool>,
775 ) -> Result<(), SubsonicError> {
776 let mut extra: Vec<(&str, String)> = vec![("playlistId", id.to_string())];
777 if let Some(name) = name {
778 extra.push(("name", name.to_string()));
779 }
780 if let Some(comment) = comment {
781 extra.push(("comment", comment.to_string()));
782 }
783 if let Some(public) = public {
784 extra.push(("public", public.to_string()));
785 }
786 let borrowed: Vec<(&str, &str)> = extra.iter().map(|(k, v)| (*k, v.as_str())).collect();
787 self.get_with_params("updatePlaylist", &borrowed)?;
788 Ok(())
789 }
790
791 pub fn delete_playlist(&self, id: &str) -> Result<(), SubsonicError> {
792 self.get_with_params("deletePlaylist", &[("id", id)])?;
793 Ok(())
794 }
795
796 pub fn koan_users(&self) -> Result<Vec<KoanUser>, SubsonicError> {
799 Ok(self
800 .get("koanUsers")?
801 .users
802 .map(|u| u.user)
803 .unwrap_or_default())
804 }
805
806 pub fn koan_create_user(
808 &self,
809 username: &str,
810 role: &str,
811 ) -> Result<KoanInvite, SubsonicError> {
812 self.get_with_params("koanCreateUser", &[("username", username), ("role", role)])?
813 .invite
814 .ok_or(SubsonicError::BadResponse)
815 }
816
817 pub fn koan_invite(&self, username: &str, reset: bool) -> Result<KoanInvite, SubsonicError> {
820 let reset = if reset { "true" } else { "false" };
821 self.get_with_params("koanInvite", &[("username", username), ("reset", reset)])?
822 .invite
823 .ok_or(SubsonicError::BadResponse)
824 }
825
826 pub fn koan_revoke_own_key(&self) -> Result<(), SubsonicError> {
828 self.get("koanRevokeKey")?;
829 Ok(())
830 }
831
832 pub fn koan_pair_info(&self, pair: &str) -> Result<KoanPair, SubsonicError> {
835 self.get_with_params("koanPairInfo", &[("pair", pair)])?
836 .pair
837 .ok_or(SubsonicError::BadResponse)
838 }
839
840 pub fn koan_pair_approve(&self, pair: &str, decline: bool) -> Result<String, SubsonicError> {
843 let decline = if decline { "true" } else { "false" };
844 self.get_with_params("koanPairApprove", &[("pair", pair), ("decline", decline)])?
845 .pair
846 .map(|p| p.device)
847 .ok_or(SubsonicError::BadResponse)
848 }
849
850 pub fn koan_set_user_password(
853 &self,
854 username: &str,
855 password: &str,
856 ) -> Result<(), SubsonicError> {
857 self.post_with_params(
858 "koanSetUserPassword",
859 &[("username", username), ("password", password)],
860 )?;
861 Ok(())
862 }
863
864 pub fn koan_change_own_password(
868 &self,
869 current: &str,
870 password: &str,
871 device: &str,
872 ) -> Result<KoanJoined, SubsonicError> {
873 self.post_with_params(
874 "koanSetUserPassword",
875 &[
876 ("current", current),
877 ("password", password),
878 ("name", device),
879 ],
880 )?
881 .join
882 .ok_or(SubsonicError::BadResponse)
883 }
884
885 pub fn koan_mcp(&self) -> Result<KoanMcp, SubsonicError> {
888 self.get("koanMcp")?.mcp.ok_or(SubsonicError::BadResponse)
889 }
890
891 pub fn koan_api_keys(&self) -> Result<Vec<KoanApiKey>, SubsonicError> {
894 Ok(self
895 .get("koanApiKeys")?
896 .api_keys
897 .map(|k| k.api_key)
898 .unwrap_or_default())
899 }
900
901 pub fn koan_create_api_key(&self, name: &str) -> Result<KoanApiKey, SubsonicError> {
903 self.post_with_params("koanCreateApiKey", &[("name", name)])?
904 .api_key
905 .ok_or(SubsonicError::BadResponse)
906 }
907
908 pub fn koan_revoke_api_key(&self, id: i64) -> Result<(), SubsonicError> {
909 self.post_with_params("koanRevokeApiKey", &[("id", &id.to_string())])?;
910 Ok(())
911 }
912
913 pub fn koan_app_passwords(&self) -> Result<Vec<KoanAppPassword>, SubsonicError> {
916 Ok(self
917 .get("koanAppPasswords")?
918 .app_passwords
919 .map(|p| p.app_password)
920 .unwrap_or_default())
921 }
922
923 pub fn koan_create_app_password(&self, name: &str) -> Result<KoanAppPassword, SubsonicError> {
926 self.post_with_params("koanCreateAppPassword", &[("name", name)])?
927 .app_password
928 .ok_or(SubsonicError::BadResponse)
929 }
930
931 pub fn koan_revoke_app_password(&self, id: i64) -> Result<(), SubsonicError> {
932 self.post_with_params("koanRevokeAppPassword", &[("id", &id.to_string())])?;
933 Ok(())
934 }
935
936 pub fn koan_set_user_role(&self, username: &str, role: &str) -> Result<(), SubsonicError> {
937 self.get_with_params("koanSetUserRole", &[("username", username), ("role", role)])?;
938 Ok(())
939 }
940
941 pub fn koan_delete_user(&self, username: &str) -> Result<(), SubsonicError> {
942 self.get_with_params("koanDeleteUser", &[("username", username)])?;
943 Ok(())
944 }
945
946 pub fn koan_command(
952 &self,
953 to: &str,
954 command: &str,
955 ack: Option<u64>,
956 ) -> Result<Option<crate::remote::acks::AckOutcome>, SubsonicError> {
957 let ack = ack.map(|a| a.to_string());
958 let mut params = vec![("to", to), ("command", command)];
959 if let Some(ack) = &ack {
960 params.push(("ack", ack));
961 }
962 Ok(self.get_with_params("koanCommand", ¶ms)?.koan_command)
963 }
964
965 pub fn koan_history(
970 &self,
971 since: crate::db::queries::HistoryCursor,
972 count: u32,
973 ) -> Result<KoanHistoryPage, SubsonicError> {
974 self.get_with_params(
975 "koanHistory",
976 &[("since", &since.to_string()), ("count", &count.to_string())],
977 )?
978 .koan_history
979 .ok_or(SubsonicError::BadResponse)
980 }
981
982 pub fn koan_forget_plays(&self, plays: &[(&str, i64)]) -> Result<(), SubsonicError> {
985 let mut pairs = Vec::new();
986 for (id, at) in plays {
987 pairs.push(("id", (*id).to_owned()));
988 pairs.push(("time", at.to_string()));
989 }
990 self.get_with_pairs("koanForgetPlays", &pairs)?;
991 Ok(())
992 }
993
994 pub fn koan_forget_plays_through(&self, at_ms: i64) -> Result<(), SubsonicError> {
996 self.get_with_params("koanForgetPlays", &[("through", &at_ms.to_string())])?;
997 Ok(())
998 }
999
1000 pub fn koan_scrobbling(&self) -> Result<KoanScrobbling, SubsonicError> {
1004 self.get("koanScrobbling")?
1005 .koan_scrobbling
1006 .ok_or(SubsonicError::BadResponse)
1007 }
1008
1009 pub fn koan_scrobbling_connect(&self, token: &str) -> Result<KoanScrobbling, SubsonicError> {
1012 self.post_with_params("koanScrobblingConnect", &[("token", token)])?
1013 .koan_scrobbling
1014 .ok_or(SubsonicError::BadResponse)
1015 }
1016
1017 pub fn koan_scrobbling_disconnect(&self) -> Result<KoanScrobbling, SubsonicError> {
1018 self.get("koanScrobblingDisconnect")?
1019 .koan_scrobbling
1020 .ok_or(SubsonicError::BadResponse)
1021 }
1022
1023 pub fn koan_dsp_profiles(&self, since: i64) -> Result<KoanDspProfiles, SubsonicError> {
1027 self.get_with_params("koanDspProfiles", &[("since", &since.to_string())])?
1028 .koan_dsp_profiles
1029 .ok_or(SubsonicError::BadResponse)
1030 }
1031
1032 pub fn koan_dsp_profile_save(
1034 &self,
1035 uid: &str,
1036 edited_at: i64,
1037 doc: &str,
1038 ) -> Result<KoanDspSaved, SubsonicError> {
1039 self.post_with_params(
1040 "koanDspProfileSave",
1041 &[
1042 ("uid", uid),
1043 ("editedAt", &edited_at.to_string()),
1044 ("doc", doc),
1045 ],
1046 )?
1047 .koan_dsp_saved
1048 .ok_or(SubsonicError::BadResponse)
1049 }
1050
1051 pub fn koan_dsp_profile_delete(
1052 &self,
1053 uid: &str,
1054 edited_at: i64,
1055 ) -> Result<KoanDspSaved, SubsonicError> {
1056 self.post_with_params(
1057 "koanDspProfileDelete",
1058 &[("uid", uid), ("editedAt", &edited_at.to_string())],
1059 )?
1060 .koan_dsp_saved
1061 .ok_or(SubsonicError::BadResponse)
1062 }
1063
1064 pub fn koan_dsp_deleted(&self) -> Result<Vec<KoanDspDeletedProfile>, SubsonicError> {
1067 Ok(self
1068 .get("koanDspDeleted")?
1069 .koan_dsp_deleted
1070 .map(|d| d.profile)
1071 .unwrap_or_default())
1072 }
1073
1074 pub fn koan_dsp_restore(&self, uid: &str) -> Result<KoanDspSaved, SubsonicError> {
1076 self.post_with_params("koanDspRestore", &[("uid", uid)])?
1077 .koan_dsp_saved
1078 .ok_or(SubsonicError::BadResponse)
1079 }
1080
1081 pub fn koan_dsp_dismiss(&self, output: &str) -> Result<(), SubsonicError> {
1083 self.post_with_params("koanDspDismiss", &[("output", output)])?;
1084 Ok(())
1085 }
1086
1087 pub fn koan_dsp_file(&self, sha256: &str) -> Result<Vec<u8>, SubsonicError> {
1089 let url = format!("{}/rest/koanDspFile", self.auth.base_url);
1090 let mut params = self.auth_params()?;
1091 params.insert("sha256".into(), sha256.into());
1092 let resp = self
1093 .http()
1094 .get(&url)
1095 .query(¶ms)
1096 .timeout(DSP_FILE_TIMEOUT)
1097 .send()?;
1098 let binary = resp
1099 .headers()
1100 .get(reqwest::header::CONTENT_TYPE)
1101 .is_some_and(|t| t.as_bytes().starts_with(b"application/octet-stream"));
1102 if !binary {
1103 let resp: SubsonicResponseWrapper = resp.json()?;
1104 resp.subsonic_response.ok()?;
1105 return Err(SubsonicError::BadResponse);
1106 }
1107 Ok(resp.bytes()?.to_vec())
1108 }
1109
1110 pub fn koan_dsp_upload(&self, sha256: &str, data: Vec<u8>) -> Result<(), SubsonicError> {
1112 let url = format!("{}/rest/koanDspFile", self.auth.base_url);
1113 let mut params = self.auth_params()?;
1114 params.insert("sha256".into(), sha256.into());
1115 let resp: SubsonicResponseWrapper = self
1116 .http()
1117 .post(&url)
1118 .query(¶ms)
1119 .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
1120 .body(data)
1121 .timeout(DSP_FILE_TIMEOUT)
1122 .send()?
1123 .json()?;
1124 resp.subsonic_response.ok()?;
1125 Ok(())
1126 }
1127
1128 pub fn get_play_queue(&self, by_index: bool) -> Result<Option<SavedPlayQueue>, SubsonicError> {
1131 let resp = self.get(if by_index {
1132 "getPlayQueueByIndex"
1133 } else {
1134 "getPlayQueue"
1135 })?;
1136 Ok(resp.play_queue_by_index.or(resp.play_queue))
1137 }
1138
1139 #[allow(clippy::too_many_arguments)]
1147 pub fn save_play_queue(
1148 &self,
1149 song_ids: &[String],
1150 current: Option<usize>,
1151 position_ms: u64,
1152 by_index: bool,
1153 form: bool,
1154 client: &str,
1155 ) -> Result<(), SubsonicError> {
1156 let endpoint = if by_index {
1157 "savePlayQueueByIndex"
1158 } else {
1159 "savePlayQueue"
1160 };
1161 let url = format!("{}/rest/{endpoint}", self.auth.base_url);
1162 let mut params = self.auth_params()?;
1163 params.insert("c".into(), client.to_string());
1164 let mut form_pairs: Vec<(String, String)> = params.into_iter().collect();
1165 form_pairs.extend(song_ids.iter().map(|id| ("id".to_string(), id.clone())));
1166 if !song_ids.is_empty() {
1169 let at = current.filter(|at| *at < song_ids.len()).unwrap_or(0);
1170 form_pairs.push(if by_index {
1171 ("currentIndex".into(), at.to_string())
1172 } else {
1173 ("current".into(), song_ids[at].clone())
1174 });
1175 form_pairs.push(("position".into(), position_ms.to_string()));
1176 }
1177 let request = if form {
1178 let body = url::form_urlencoded::Serializer::new(String::new())
1179 .extend_pairs(&form_pairs)
1180 .finish();
1181 self.http()
1182 .post(&url)
1183 .header(
1184 reqwest::header::CONTENT_TYPE,
1185 "application/x-www-form-urlencoded",
1186 )
1187 .body(body)
1188 } else {
1189 self.http().get(&url).query(&form_pairs)
1190 };
1191 let resp: SubsonicResponseWrapper = request.send()?.json()?;
1192 resp.subsonic_response.ok().map(|_| ())
1193 }
1194
1195 pub fn auth(&self) -> &SubsonicAuth {
1196 &self.auth
1197 }
1198
1199 pub fn base_url(&self) -> &str {
1201 &self.auth.base_url
1202 }
1203}
1204
1205#[derive(Debug, Deserialize)]
1208struct SubsonicResponseWrapper {
1209 #[serde(rename = "subsonic-response")]
1210 subsonic_response: SubsonicResponse,
1211}
1212
1213#[derive(Debug, Deserialize)]
1214#[serde(rename_all = "camelCase")]
1215struct SubsonicResponse {
1216 status: String,
1217 #[serde(rename = "type")]
1220 server_type: Option<String>,
1221 server_version: Option<String>,
1222 #[serde(default)]
1223 open_subsonic: bool,
1224 open_subsonic_extensions: Option<Vec<SubsonicExtension>>,
1225 error: Option<SubsonicApiError>,
1226 artists: Option<SubsonicArtists>,
1227 album: Option<SubsonicAlbumFull>,
1228 album_list2: Option<SubsonicAlbumList>,
1229 search_result3: Option<SubsonicSearchResult>,
1230 starred2: Option<SubsonicStarred>,
1231 shares: Option<SubsonicShares>,
1232 similar_songs2: Option<SubsonicSimilarSongs>,
1233 playlists: Option<SubsonicPlaylists>,
1234 playlist: Option<SubsonicPlaylistFull>,
1235 scan_status: Option<SubsonicScanStatus>,
1236 indexes: Option<SubsonicIndexes>,
1237 users: Option<KoanUsers>,
1238 api_keys: Option<KoanApiKeys>,
1239 api_key: Option<KoanApiKey>,
1240 app_passwords: Option<KoanAppPasswords>,
1241 app_password: Option<KoanAppPassword>,
1242 mcp: Option<KoanMcp>,
1243 invite: Option<KoanInvite>,
1244 join: Option<KoanJoined>,
1245 pair: Option<KoanPair>,
1246 koan_history: Option<KoanHistoryPage>,
1247 play_queue: Option<SavedPlayQueue>,
1248 play_queue_by_index: Option<SavedPlayQueue>,
1249 koan_command: Option<crate::remote::acks::AckOutcome>,
1250 koan_scrobbling: Option<KoanScrobbling>,
1251 koan_dsp_profiles: Option<KoanDspProfiles>,
1252 koan_dsp_saved: Option<KoanDspSaved>,
1253 koan_dsp_deleted: Option<KoanDspDeleted>,
1254}
1255
1256#[derive(Debug, Clone, Default, Deserialize)]
1259#[serde(rename_all = "camelCase")]
1260pub struct SavedPlayQueue {
1261 #[serde(default)]
1262 pub entry: Vec<SubsonicSong>,
1263 pub current: Option<String>,
1264 pub current_index: Option<usize>,
1265 #[serde(default)]
1266 pub position: u64,
1267 #[serde(default)]
1268 pub changed_by: String,
1269 pub changed: Option<String>,
1270}
1271
1272impl SavedPlayQueue {
1273 pub fn current_at(&self) -> Option<usize> {
1276 self.current_index
1277 .filter(|at| *at < self.entry.len())
1278 .or_else(|| {
1279 let current = self.current.as_deref()?;
1280 self.entry.iter().position(|e| e.id == current)
1281 })
1282 }
1283}
1284
1285const DSP_FILE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
1288
1289#[derive(Debug, Clone, Default, Deserialize)]
1291pub struct KoanDspProfiles {
1292 pub cursor: i64,
1294 #[serde(default)]
1295 pub profile: Vec<KoanDspProfile>,
1296 #[serde(default)]
1298 pub dismissed: Vec<KoanDspDismissed>,
1299}
1300
1301#[derive(Debug, Clone, Deserialize)]
1302#[serde(rename_all = "camelCase")]
1303pub struct KoanDspProfile {
1304 pub uid: String,
1305 pub rev: i64,
1306 pub edited_at: i64,
1307 pub doc: Option<String>,
1309}
1310
1311#[derive(Debug, Clone, Deserialize)]
1312pub struct KoanDspDismissed {
1313 pub output: String,
1314}
1315
1316#[derive(Debug, Clone, Default, Deserialize)]
1317struct KoanDspDeleted {
1318 #[serde(default)]
1319 profile: Vec<KoanDspDeletedProfile>,
1320}
1321
1322#[derive(Debug, Clone, Deserialize)]
1325#[serde(rename_all = "camelCase")]
1326pub struct KoanDspDeletedProfile {
1327 pub uid: String,
1328 pub name: String,
1329 pub deleted_at: i64,
1330 pub expires_at: i64,
1331}
1332
1333#[derive(Debug, Clone, Default, Deserialize)]
1335pub struct KoanDspSaved {
1336 pub rev: i64,
1337 pub stored: bool,
1339 #[serde(default)]
1341 pub missing: Vec<KoanDspMissing>,
1342}
1343
1344#[derive(Debug, Clone, Deserialize)]
1345pub struct KoanDspMissing {
1346 pub sha256: String,
1347}
1348
1349#[derive(Debug, Clone, Default, Deserialize)]
1352pub struct KoanScrobbling {
1353 #[serde(default)]
1354 pub service: Vec<KoanScrobbleService>,
1355}
1356
1357#[derive(Debug, Clone, Deserialize)]
1358pub struct KoanScrobbleService {
1359 pub name: String,
1361 pub account: String,
1363 pub connected: i64,
1365 #[serde(default)]
1367 pub pending: i64,
1368 pub error: Option<String>,
1370}
1371
1372#[derive(Debug, Clone, Default, Deserialize)]
1374pub struct KoanHistoryPage {
1375 pub cursor: String,
1377 #[serde(default)]
1378 pub more: bool,
1379 #[serde(default)]
1380 pub play: Vec<KoanPlay>,
1381 #[serde(default)]
1382 pub forgotten: Vec<KoanForgotten>,
1383}
1384
1385#[derive(Debug, Clone, Deserialize)]
1386#[serde(rename_all = "camelCase")]
1387pub struct KoanPlay {
1388 pub id: String,
1390 #[serde(default)]
1392 pub seq: Option<i64>,
1393 pub played: i64,
1395 pub listened_ms: Option<i64>,
1396}
1397
1398#[derive(Debug, Clone, Deserialize)]
1400pub struct KoanForgotten {
1401 pub id: Option<String>,
1402 pub played: i64,
1403}
1404
1405#[derive(Debug, Clone, Deserialize)]
1409pub struct KoanPair {
1410 pub device: String,
1411 #[serde(default)]
1412 pub from: String,
1413 #[serde(default)]
1414 pub local: bool,
1415}
1416
1417#[derive(Debug, Clone, Deserialize)]
1419pub struct KoanMcp {
1420 pub url: String,
1421 pub connect: String,
1422}
1423
1424#[derive(Debug, Deserialize)]
1425#[serde(rename_all = "camelCase")]
1426struct KoanApiKeys {
1427 #[serde(default)]
1428 api_key: Vec<KoanApiKey>,
1429}
1430
1431#[derive(Debug, Clone, Deserialize)]
1434#[serde(rename_all = "camelCase")]
1435pub struct KoanApiKey {
1436 pub id: i64,
1437 pub name: String,
1438 pub created: Option<String>,
1440 pub last_used: Option<String>,
1441 #[serde(default)]
1443 pub current: bool,
1444 pub key: Option<String>,
1445}
1446
1447#[derive(Debug, Deserialize)]
1448#[serde(rename_all = "camelCase")]
1449struct KoanAppPasswords {
1450 #[serde(default)]
1451 app_password: Vec<KoanAppPassword>,
1452}
1453
1454#[derive(Debug, Clone, Deserialize)]
1457#[serde(rename_all = "camelCase")]
1458pub struct KoanAppPassword {
1459 pub id: i64,
1460 pub name: String,
1461 pub created: Option<String>,
1463 pub last_used: Option<String>,
1464 pub password: Option<String>,
1465}
1466
1467#[derive(Debug, Deserialize)]
1468struct KoanUsers {
1469 #[serde(default)]
1470 user: Vec<KoanUser>,
1471}
1472
1473#[derive(Debug, Clone, Deserialize)]
1475pub struct KoanUser {
1476 pub username: String,
1477 pub role: String,
1478}
1479
1480#[derive(Debug, Clone, Deserialize)]
1484pub struct KoanInvite {
1485 pub username: String,
1486 pub token: String,
1487 pub password: Option<String>,
1488}
1489
1490#[derive(Debug, Clone, Deserialize)]
1493#[serde(rename_all = "camelCase")]
1494pub struct KoanJoined {
1495 pub username: String,
1496 pub api_key: String,
1497}
1498
1499pub fn redeem_invite(
1502 base_url: &str,
1503 token: &str,
1504 device: &str,
1505) -> Result<KoanJoined, SubsonicError> {
1506 let url = format!("{}/rest/koanJoin", base_url.trim_end_matches('/'));
1507 let resp: SubsonicResponseWrapper = download::api_client()?
1508 .get(&url)
1509 .query(&[
1510 ("invite", token),
1511 ("name", device),
1512 ("v", API_VERSION),
1513 ("c", CLIENT_NAME),
1514 ("f", "json"),
1515 ])
1516 .send()?
1517 .json()?;
1518 resp.subsonic_response
1519 .ok()?
1520 .join
1521 .ok_or(SubsonicError::BadResponse)
1522}
1523
1524pub fn offers_unsigned(base_url: &str, extension: &str) -> Result<bool, SubsonicError> {
1528 let url = format!(
1529 "{}/rest/getOpenSubsonicExtensions",
1530 base_url.trim_end_matches('/')
1531 );
1532 let resp: SubsonicResponseWrapper = download::api_client()?
1533 .get(&url)
1534 .query(&[("v", API_VERSION), ("c", CLIENT_NAME), ("f", "json")])
1535 .send()?
1536 .json()?;
1537 Ok(resp.subsonic_response.ok()?.has_extension(extension))
1538}
1539
1540pub fn koan_sign_in(
1545 base_url: &str,
1546 username: &str,
1547 password: &str,
1548 device: &str,
1549) -> Result<KoanJoined, SubsonicError> {
1550 let url = format!("{}/rest/koanSignIn", base_url.trim_end_matches('/'));
1551 let hex: String = password.bytes().map(|b| format!("{b:02x}")).collect();
1552 let p = format!("enc:{hex}");
1553 let resp: SubsonicResponseWrapper = download::api_client()?
1554 .get(&url)
1555 .query(&[
1556 ("u", username),
1557 ("p", &p),
1558 ("name", device),
1559 ("v", API_VERSION),
1560 ("c", CLIENT_NAME),
1561 ("f", "json"),
1562 ])
1563 .send()?
1564 .json()?;
1565 resp.subsonic_response
1566 .ok()?
1567 .join
1568 .ok_or(SubsonicError::BadResponse)
1569}
1570
1571impl SubsonicResponse {
1572 fn ok(self) -> Result<Self, SubsonicError> {
1573 if self.status == "ok" {
1574 return Ok(self);
1575 }
1576 Err(self
1577 .error
1578 .map_or(SubsonicError::BadResponse, |err| SubsonicError::Api {
1579 code: err.code,
1580 message: err.message,
1581 }))
1582 }
1583
1584 fn has_extension(&self, name: &str) -> bool {
1585 self.open_subsonic_extensions
1586 .iter()
1587 .flatten()
1588 .any(|ext| ext.name == name)
1589 }
1590}
1591
1592#[derive(Debug, Deserialize)]
1593struct SubsonicExtension {
1594 name: String,
1595 #[serde(default)]
1596 versions: Vec<i64>,
1597}
1598
1599#[derive(Debug, Deserialize)]
1600#[serde(rename_all = "camelCase")]
1601struct SubsonicIndexes {
1602 last_modified: Option<i64>,
1603}
1604
1605#[derive(Debug, Deserialize)]
1606struct SubsonicScanStatus {
1607 count: Option<u64>,
1608}
1609
1610#[derive(Debug, Deserialize)]
1611struct SubsonicApiError {
1612 code: i32,
1613 message: String,
1614}
1615
1616#[derive(Debug, Deserialize)]
1617struct SubsonicArtists {
1618 index: Vec<SubsonicArtistIndex>,
1619}
1620
1621#[derive(Debug, Deserialize)]
1622struct SubsonicArtistIndex {
1623 artist: Vec<SubsonicArtist>,
1624}
1625
1626#[derive(Debug, Clone, Deserialize)]
1627#[serde(rename_all = "camelCase")]
1628pub struct SubsonicArtist {
1629 pub id: String,
1630 pub name: String,
1631 pub album_count: Option<i32>,
1632 #[serde(default, deserialize_with = "non_empty")]
1635 pub music_brainz_id: Option<String>,
1636 #[serde(default, deserialize_with = "non_empty")]
1637 pub sort_name: Option<String>,
1638}
1639
1640#[derive(Debug, Clone, Deserialize)]
1641#[serde(rename_all = "camelCase")]
1642pub struct SubsonicAlbum {
1643 pub id: String,
1644 pub name: String,
1645 pub artist: Option<String>,
1646 pub artist_id: Option<String>,
1647 pub song_count: Option<i32>,
1648 #[serde(default)]
1650 pub duration: Option<i64>,
1651 pub year: Option<i32>,
1652 #[serde(default, deserialize_with = "non_empty")]
1653 pub genre: Option<String>,
1654 pub created: Option<String>,
1655 #[serde(default, deserialize_with = "non_empty")]
1658 pub music_brainz_id: Option<String>,
1659 #[serde(default, deserialize_with = "non_empty")]
1660 pub sort_name: Option<String>,
1661 #[serde(default)]
1662 pub record_labels: Vec<SubsonicName>,
1663}
1664
1665#[derive(Debug, Clone, Deserialize)]
1668pub struct SubsonicName {
1669 pub name: String,
1670}
1671
1672#[derive(Debug, Clone, Deserialize)]
1673#[serde(rename_all = "camelCase")]
1674pub struct SubsonicAlbumFull {
1675 pub id: String,
1676 pub name: String,
1677 pub artist: Option<String>,
1678 pub artist_id: Option<String>,
1679 pub year: Option<i32>,
1680 #[serde(default, deserialize_with = "non_empty")]
1681 pub genre: Option<String>,
1682 pub song_count: Option<i32>,
1683 pub created: Option<String>,
1684 #[serde(default, deserialize_with = "non_empty")]
1685 pub music_brainz_id: Option<String>,
1686 #[serde(default, deserialize_with = "non_empty")]
1687 pub sort_name: Option<String>,
1688 #[serde(default)]
1689 pub record_labels: Vec<SubsonicName>,
1690 #[serde(default)]
1691 pub song: Vec<SubsonicSong>,
1692}
1693
1694#[derive(Debug, Clone, Deserialize)]
1695#[serde(rename_all = "camelCase")]
1696pub struct SubsonicSong {
1697 pub id: String,
1698 pub title: String,
1699 pub album: Option<String>,
1700 pub artist: Option<String>,
1701 pub track: Option<i32>,
1702 pub disc_number: Option<i32>,
1703 pub year: Option<i32>,
1704 #[serde(default, deserialize_with = "non_empty")]
1705 pub genre: Option<String>,
1706 pub duration: Option<i64>,
1707 pub bit_rate: Option<i32>,
1708 pub suffix: Option<String>,
1709 pub content_type: Option<String>,
1710 #[serde(default, deserialize_with = "non_empty")]
1712 pub codec: Option<String>,
1713 pub album_id: Option<String>,
1714 pub artist_id: Option<String>,
1715 pub sampling_rate: Option<i32>,
1718 pub bit_depth: Option<i32>,
1719 pub channel_count: Option<i32>,
1720 #[serde(default, deserialize_with = "non_empty")]
1721 pub music_brainz_id: Option<String>,
1722}
1723
1724#[derive(Debug, Deserialize)]
1725struct SubsonicAlbumList {
1726 #[serde(default)]
1727 album: Vec<SubsonicAlbum>,
1728}
1729
1730#[derive(Debug, Default, Deserialize)]
1731pub struct SubsonicSearchResult {
1732 #[serde(default)]
1733 pub artist: Vec<SubsonicArtist>,
1734 #[serde(default)]
1735 pub album: Vec<SubsonicAlbum>,
1736 #[serde(default)]
1737 pub song: Vec<SubsonicSong>,
1738}
1739
1740#[derive(Debug, Default, Deserialize)]
1741pub struct SubsonicStarred {
1742 #[serde(default)]
1743 pub song: Vec<SubsonicSong>,
1744 #[serde(default)]
1745 pub album: Vec<SubsonicAlbum>,
1746 #[serde(default)]
1747 pub artist: Vec<SubsonicArtist>,
1748}
1749
1750#[derive(Debug, Deserialize)]
1751pub struct SubsonicSimilarSongs {
1752 pub song: Option<Vec<SubsonicSong>>,
1753}
1754
1755#[derive(Debug, Default, Deserialize)]
1756struct SubsonicPlaylists {
1757 #[serde(default)]
1758 playlist: Vec<SubsonicPlaylist>,
1759}
1760
1761#[derive(Debug, Clone, Deserialize)]
1763#[serde(rename_all = "camelCase")]
1764pub struct SubsonicPlaylist {
1765 pub id: String,
1766 pub name: String,
1767 pub comment: Option<String>,
1768 pub owner: Option<String>,
1769 #[serde(default)]
1770 pub public: bool,
1771 pub song_count: Option<i64>,
1772 pub duration: Option<i64>,
1773 pub created: Option<String>,
1774 pub changed: Option<String>,
1775 #[serde(default)]
1778 pub readonly: bool,
1779}
1780
1781#[derive(Debug, Clone, Deserialize)]
1782#[serde(rename_all = "camelCase")]
1783pub struct SubsonicPlaylistFull {
1784 #[serde(flatten)]
1785 pub playlist: SubsonicPlaylist,
1786 #[serde(default)]
1787 pub entry: Vec<SubsonicSong>,
1788}
1789
1790#[derive(Debug, Deserialize)]
1791struct SubsonicShares {
1792 #[serde(default)]
1793 share: Vec<SubsonicShare>,
1794}
1795
1796#[derive(Debug, Clone, Deserialize)]
1797#[serde(rename_all = "camelCase")]
1798pub struct SubsonicShare {
1799 pub id: String,
1800 pub url: Option<String>,
1801 pub description: Option<String>,
1802 pub username: Option<String>,
1803 pub created: Option<String>,
1804 pub expires: Option<String>,
1805 pub visit_count: Option<i64>,
1806}
1807
1808fn non_empty<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
1815 Ok(Option::<String>::deserialize(d)?.filter(|s| !s.is_empty()))
1816}
1817
1818fn random_salt() -> Result<String, getrandom::Error> {
1825 let mut buf = [0u8; 12];
1826 getrandom::fill(&mut buf)?;
1827 Ok(buf.iter().map(|b| format!("{:02x}", b)).collect())
1828}
1829
1830#[cfg(test)]
1831mod tests {
1832 use std::time::{Duration, Instant};
1833
1834 use super::*;
1835
1836 #[test]
1837 fn a_network_change_tries_a_server_that_was_not_answering_now() {
1838 let client = SubsonicClient::new("http://192.168.1.10:4533", "koan", "pw");
1839 client.outage().down(
1840 Some(Duration::from_secs(60)),
1841 "Can't reach the server".into(),
1842 );
1843
1844 let started = Instant::now();
1845 std::thread::scope(|scope| {
1846 scope.spawn(|| {
1847 std::thread::sleep(Duration::from_millis(100));
1848 client.renew();
1849 });
1850 client.outage().hold();
1851 });
1852 assert!(
1853 started.elapsed() < Duration::from_secs(5),
1854 "renewed: the next try is due now, not in a minute"
1855 );
1856 assert_eq!(
1857 client.outage().reason().as_deref(),
1858 Some("Can't reach the server"),
1859 "still down until a try answers"
1860 );
1861 }
1862
1863 #[test]
1864 fn a_saved_play_queue_reads_by_index_or_by_id() {
1865 let parse = |json: &str| {
1866 serde_json::from_str::<SubsonicResponseWrapper>(json)
1867 .unwrap()
1868 .subsonic_response
1869 };
1870 let entries = r#"[{"id":"a","title":"A"},{"id":"b","title":"B"},{"id":"a","title":"A"}]"#;
1871 let by_index = parse(&format!(
1872 r#"{{"subsonic-response":{{"status":"ok","playQueueByIndex":{{"entry":{entries},"currentIndex":2,"position":1500,"changedBy":"koan d1"}}}}}}"#
1873 ));
1874 let q = by_index.play_queue_by_index.unwrap();
1875 assert_eq!(
1876 (q.current_at(), q.position, q.changed_by.as_str()),
1877 (Some(2), 1500, "koan d1")
1878 );
1879
1880 let by_id = parse(&format!(
1882 r#"{{"subsonic-response":{{"status":"ok","playQueue":{{"entry":{entries},"current":"a"}}}}}}"#
1883 ));
1884 assert_eq!(by_id.play_queue.unwrap().current_at(), Some(0));
1885 let gone = parse(&format!(
1886 r#"{{"subsonic-response":{{"status":"ok","playQueue":{{"entry":{entries},"current":"z"}}}}}}"#
1887 ));
1888 assert_eq!(gone.play_queue.unwrap().current_at(), None);
1889 }
1890
1891 fn response(json: &str) -> SubsonicResponse {
1892 serde_json::from_str::<SubsonicResponseWrapper>(json)
1893 .unwrap()
1894 .subsonic_response
1895 }
1896
1897 #[test]
1898 fn http_errors_leave_the_signed_url_out() {
1899 let e = reqwest::blocking::get("http://127.0.0.1:1/rest/stream?u=owner&p=enc:736563726574")
1901 .unwrap_err();
1902 assert!(e.to_string().contains("enc:736563726574"));
1903 let shown = SubsonicError::from(e).to_string();
1904 assert!(!shown.contains("enc:"), "{shown}");
1905 let e = reqwest::blocking::get("http://127.0.0.1:1/rest/stream?p=enc:736563726574")
1906 .unwrap_err();
1907 assert!(!DownloadError::from(e).to_string().contains("enc:"));
1908 }
1909
1910 #[test]
1911 fn playback_report_is_read_from_the_advertised_extensions() {
1912 let navidrome = response(
1913 r#"{"subsonic-response": {
1914 "status": "ok", "version": "1.16.1", "type": "navidrome",
1915 "openSubsonic": true,
1916 "openSubsonicExtensions": [
1917 {"name": "transcodeOffset", "versions": [1]},
1918 {"name": "playbackReport", "versions": [1]}
1919 ]
1920 }}"#,
1921 );
1922 assert!(navidrome.has_extension(PLAYBACK_REPORT_EXTENSION));
1923
1924 let without = response(
1925 r#"{"subsonic-response": {
1926 "status": "ok", "version": "1.16.1",
1927 "openSubsonicExtensions": [{"name": "songLyrics", "versions": [1, 2]}]
1928 }}"#,
1929 );
1930 assert!(!without.has_extension(PLAYBACK_REPORT_EXTENSION));
1931
1932 let plain = response(r#"{"subsonic-response": {"status": "ok", "version": "1.16.1"}}"#);
1933 assert!(!plain.has_extension(PLAYBACK_REPORT_EXTENSION));
1934 }
1935
1936 #[test]
1939 fn test_deserialize_subsonic_song() {
1940 let json = r#"{
1941 "id": "42",
1942 "title": "Space Oddity",
1943 "album": "Space Oddity",
1944 "artist": "David Bowie",
1945 "track": 1,
1946 "discNumber": 1,
1947 "year": 1969,
1948 "genre": "Rock",
1949 "duration": 314,
1950 "bitRate": 320,
1951 "suffix": "mp3",
1952 "contentType": "audio/mpeg",
1953 "albumId": "7",
1954 "artistId": "3"
1955 }"#;
1956
1957 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1958
1959 assert_eq!(song.id, "42");
1960 assert_eq!(song.title, "Space Oddity");
1961 assert_eq!(song.album.as_deref(), Some("Space Oddity"));
1962 assert_eq!(song.artist.as_deref(), Some("David Bowie"));
1963 assert_eq!(song.track, Some(1));
1964 assert_eq!(song.disc_number, Some(1));
1965 assert_eq!(song.year, Some(1969));
1966 assert_eq!(song.genre.as_deref(), Some("Rock"));
1967 assert_eq!(song.duration, Some(314));
1968 assert_eq!(song.bit_rate, Some(320));
1969 assert_eq!(song.suffix.as_deref(), Some("mp3"));
1970 assert_eq!(song.content_type.as_deref(), Some("audio/mpeg"));
1971 assert_eq!(song.album_id.as_deref(), Some("7"));
1972 assert_eq!(song.artist_id.as_deref(), Some("3"));
1973 }
1974
1975 #[test]
1979 fn opensubsonic_quality_fields_are_read() {
1980 let json = r#"{
1981 "id": "000XtGC7jsWEbOjDsZi4Xw",
1982 "title": "Anguish",
1983 "suffix": "flac",
1984 "bitRate": 913,
1985 "samplingRate": 44100,
1986 "bitDepth": 16,
1987 "channelCount": 2
1988 }"#;
1989
1990 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1991
1992 assert_eq!(song.sampling_rate, Some(44100));
1993 assert_eq!(song.bit_depth, Some(16));
1994 assert_eq!(song.channel_count, Some(2));
1995 }
1996
1997 #[test]
2000 fn a_koan_server_names_the_codec() {
2001 let json = r#"{"id": "1", "title": "Seawhite", "suffix": "m4a", "codec": "ALAC"}"#;
2002 let song: SubsonicSong = serde_json::from_str(json).unwrap();
2003 assert_eq!(song.codec.as_deref(), Some("ALAC"));
2004
2005 let json = r#"{"id": "1", "title": "Seawhite", "suffix": "m4a"}"#;
2006 let song: SubsonicSong = serde_json::from_str(json).unwrap();
2007 assert_eq!(song.codec, None);
2008 }
2009
2010 #[test]
2013 fn a_plain_subsonic_song_has_no_quality_figures() {
2014 let json = r#"{"id": "1", "title": "Track", "bitRate": 320}"#;
2015 let song: SubsonicSong = serde_json::from_str(json).unwrap();
2016
2017 assert_eq!(song.sampling_rate, None);
2018 assert_eq!(song.bit_depth, None);
2019 assert_eq!(song.channel_count, None);
2020 }
2021
2022 #[test]
2023 fn test_deserialize_subsonic_song_optional_fields_absent() {
2024 let json = r#"{"id": "99", "title": "Minimal Track"}"#;
2026
2027 let song: SubsonicSong = serde_json::from_str(json).unwrap();
2028
2029 assert_eq!(song.id, "99");
2030 assert_eq!(song.title, "Minimal Track");
2031 assert!(song.album.is_none());
2032 assert!(song.artist.is_none());
2033 assert!(song.track.is_none());
2034 assert!(song.disc_number.is_none());
2035 assert!(song.year.is_none());
2036 assert!(song.duration.is_none());
2037 assert!(song.bit_rate.is_none());
2038 }
2039
2040 #[test]
2043 fn test_deserialize_album_list() {
2044 let json = r#"{
2045 "subsonic-response": {
2046 "status": "ok",
2047 "version": "1.16.1",
2048 "albumList2": {
2049 "album": [
2050 {
2051 "id": "1",
2052 "name": "Abbey Road",
2053 "artist": "The Beatles",
2054 "artistId": "10",
2055 "songCount": 17,
2056 "year": 1969,
2057 "genre": "Rock",
2058 "created": "2020-01-01T00:00:00"
2059 },
2060 {
2061 "id": "2",
2062 "name": "Led Zeppelin IV",
2063 "artist": "Led Zeppelin",
2064 "artistId": "11",
2065 "songCount": 8,
2066 "year": 1971,
2067 "genre": "Hard Rock",
2068 "created": "2020-01-02T00:00:00"
2069 }
2070 ]
2071 }
2072 }
2073 }"#;
2074
2075 let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
2076 let album_list = wrapper
2077 .subsonic_response
2078 .album_list2
2079 .expect("album_list2 should be present");
2080
2081 assert_eq!(album_list.album.len(), 2);
2082
2083 let first = &album_list.album[0];
2084 assert_eq!(first.id, "1");
2085 assert_eq!(first.name, "Abbey Road");
2086 assert_eq!(first.artist.as_deref(), Some("The Beatles"));
2087 assert_eq!(first.artist_id.as_deref(), Some("10"));
2088 assert_eq!(first.song_count, Some(17));
2089 assert_eq!(first.year, Some(1969));
2090
2091 let second = &album_list.album[1];
2092 assert_eq!(second.id, "2");
2093 assert_eq!(second.name, "Led Zeppelin IV");
2094 assert_eq!(second.song_count, Some(8));
2095 }
2096
2097 #[test]
2100 fn test_auth_params_format() {
2101 let client = SubsonicClient::new("http://localhost:4533", "alice", "secret");
2102 let params = client.auth_params().unwrap();
2103
2104 assert!(params.contains_key("u"), "missing 'u' param");
2106 assert!(params.contains_key("t"), "missing 't' param");
2107 assert!(params.contains_key("s"), "missing 's' param");
2108 assert!(params.contains_key("v"), "missing 'v' param");
2109 assert!(params.contains_key("c"), "missing 'c' param");
2110 assert!(params.contains_key("f"), "missing 'f' param");
2111 assert_eq!(params.len(), 6);
2112
2113 assert_eq!(params["u"], "alice");
2114 assert_eq!(params["v"], "1.16.1");
2115 assert_eq!(params["c"], "koan");
2116 assert_eq!(params["f"], "json");
2117 }
2118
2119 #[test]
2120 fn test_auth_params_over_https_send_the_hex_password() {
2121 let client = SubsonicClient::new("https://koan.example", "alice", "hi");
2122 let params = client.auth_params().unwrap();
2123 assert_eq!(params["p"], "enc:6869");
2124 assert!(!params.contains_key("t") && !params.contains_key("s"));
2125 }
2126
2127 #[test]
2128 fn test_auth_params_with_an_api_key_name_no_user() {
2129 let auth = SubsonicAuth::with(
2130 "http://koan.example",
2131 "alice",
2132 Credential::ApiKey("k3y".into()),
2133 );
2134 let params = auth.params().unwrap();
2135 assert_eq!(params.get("apiKey").map(String::as_str), Some("k3y"));
2136 for absent in ["u", "p", "t", "s"] {
2137 assert!(!params.contains_key(absent), "{absent}");
2138 }
2139 }
2140
2141 #[test]
2142 fn test_auth_params_token_is_md5_of_password_plus_salt() {
2143 let client = SubsonicClient::new("http://localhost:4533", "bob", "letmein");
2144 let params = client.auth_params().unwrap();
2145
2146 let salt = ¶ms["s"];
2147 let token = ¶ms["t"];
2148
2149 let expected = format!("{:x}", md5::compute(format!("letmein{}", salt)));
2151 assert_eq!(token, &expected);
2152 }
2153
2154 #[test]
2155 fn test_auth_params_salt_is_different_each_call() {
2156 let client = SubsonicClient::new("http://localhost:4533", "user", "pass");
2157 let params1 = client.auth_params().unwrap();
2158 let params2 = client.auth_params().unwrap();
2159
2160 assert_ne!(params1["s"], params2["s"], "salt should be random per call");
2163 }
2164
2165 #[test]
2168 fn test_stream_url_has_auth() {
2169 let client = SubsonicClient::new("http://myserver:4533", "user", "pass");
2170 let url = client.stream_url("track-123").unwrap();
2171
2172 assert!(url.contains("track-123"), "url must include the track id");
2173 assert!(url.contains("u=user"), "url must include username param");
2174 assert!(url.contains("v=1.16.1"), "url must include api version");
2175 assert!(url.contains("c=koan"), "url must include client name");
2176 assert!(url.contains("f=json"), "url must include format param");
2177 assert!(url.contains("/rest/stream"), "url must target /rest/stream");
2178 assert!(
2179 url.starts_with("http://myserver:4533"),
2180 "url must use the configured base_url"
2181 );
2182 }
2183
2184 #[test]
2185 fn test_stream_url_base_url_trailing_slash_normalised() {
2186 let client_with_slash = SubsonicClient::new("http://myserver:4533/", "u", "p");
2188 let client_no_slash = SubsonicClient::new("http://myserver:4533", "u", "p");
2189
2190 let url_with = client_with_slash.stream_url("1").unwrap();
2191 let url_without = client_no_slash.stream_url("1").unwrap();
2192
2193 assert!(
2195 url_with.contains("/rest/stream"),
2196 "should not have double slash"
2197 );
2198 assert!(!url_with.contains("//rest"), "should not have double slash");
2199 assert_eq!(
2201 url_with.split('?').next(),
2202 url_without.split('?').next(),
2203 "path segment should be identical regardless of trailing slash"
2204 );
2205 }
2206
2207 #[test]
2210 fn test_deserialize_album_full_with_songs() {
2211 let json = r#"{
2212 "id": "5",
2213 "name": "Kind of Blue",
2214 "artist": "Miles Davis",
2215 "artistId": "20",
2216 "year": 1959,
2217 "genre": "Jazz",
2218 "songCount": 5,
2219 "created": "2021-06-01T00:00:00",
2220 "song": [
2221 {"id": "101", "title": "So What"},
2222 {"id": "102", "title": "Freddie Freeloader"},
2223 {"id": "103", "title": "Blue in Green"}
2224 ]
2225 }"#;
2226
2227 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
2228
2229 assert_eq!(album.id, "5");
2230 assert_eq!(album.name, "Kind of Blue");
2231 assert_eq!(album.artist.as_deref(), Some("Miles Davis"));
2232 assert_eq!(album.year, Some(1959));
2233 assert_eq!(album.song.len(), 3);
2234 assert_eq!(album.song[0].title, "So What");
2235 assert_eq!(album.song[2].id, "103");
2236 }
2237
2238 #[test]
2239 fn test_deserialize_album_full_empty_song_list() {
2240 let json = r#"{"id": "9", "name": "No Tracks Yet"}"#;
2242
2243 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
2244
2245 assert_eq!(album.id, "9");
2246 assert!(album.song.is_empty(), "song list should default to empty");
2247 }
2248
2249 #[test]
2252 fn test_deserialize_search_result_mixed() {
2253 let json = r#"{
2254 "artist": [{"id": "1", "name": "Artist One"}],
2255 "album": [{"id": "2", "name": "Album One"}],
2256 "song": [{"id": "3", "title": "Song One"}]
2257 }"#;
2258
2259 let result: SubsonicSearchResult = serde_json::from_str(json).unwrap();
2260
2261 assert_eq!(result.artist.len(), 1);
2262 assert_eq!(result.artist[0].name, "Artist One");
2263 assert_eq!(result.album.len(), 1);
2264 assert_eq!(result.album[0].name, "Album One");
2265 assert_eq!(result.song.len(), 1);
2266 assert_eq!(result.song[0].title, "Song One");
2267 }
2268
2269 #[test]
2270 fn empty_opensubsonic_ids_are_absent() {
2271 let json = r#"{"id": "1", "title": "T", "musicBrainzId": ""}"#;
2272 let song: SubsonicSong = serde_json::from_str(json).unwrap();
2273 assert_eq!(song.music_brainz_id, None);
2274
2275 let json = r#"{"id": "2", "name": "A", "musicBrainzId": "", "sortName": ""}"#;
2276 let album: SubsonicAlbum = serde_json::from_str(json).unwrap();
2277 assert_eq!((album.music_brainz_id, album.sort_name), (None, None));
2278
2279 let json = r#"{"id": "3", "name": "A", "musicBrainzId": "mb-1"}"#;
2280 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
2281 assert_eq!(album.music_brainz_id.as_deref(), Some("mb-1"));
2282 assert_eq!(album.sort_name, None);
2283 }
2284
2285 #[test]
2286 fn deleted_dsp_profiles_read_with_their_times() {
2287 let listed = response(
2288 r#"{"subsonic-response": {"status": "ok", "koanDspDeleted": {"profile": [
2289 {"uid": "u1", "name": "Room", "deletedAt": 1000, "expiresAt": 2592001000}
2290 ]}}}"#,
2291 );
2292 let listed = listed.koan_dsp_deleted.unwrap().profile;
2293 assert_eq!(listed.len(), 1);
2294 assert_eq!(
2295 (listed[0].uid.as_str(), listed[0].name.as_str()),
2296 ("u1", "Room")
2297 );
2298 assert_eq!(
2299 (listed[0].deleted_at, listed[0].expires_at),
2300 (1000, 2_592_001_000)
2301 );
2302 let none = response(r#"{"subsonic-response": {"status": "ok", "koanDspDeleted": {}}}"#);
2303 assert!(none.koan_dsp_deleted.unwrap().profile.is_empty());
2304 }
2305
2306 #[test]
2307 fn app_passwords_read_listed_and_made() {
2308 let listed = response(
2309 r#"{"subsonic-response": {"status": "ok", "appPasswords": {"appPassword": [
2310 {"id": 3, "name": "Arpeggi", "created": "2026-10-08T09:00:00Z"},
2311 {"id": 4, "name": "Tempo", "created": "2026-10-08T10:00:00Z", "lastUsed": "2026-10-08T11:00:00Z"}
2312 ]}}}"#,
2313 );
2314 let listed = listed.app_passwords.unwrap().app_password;
2315 assert_eq!(listed.len(), 2);
2316 assert_eq!((listed[0].id, listed[0].name.as_str()), (3, "Arpeggi"));
2317 assert_eq!(listed[0].last_used, None);
2318 assert_eq!(listed[1].last_used.as_deref(), Some("2026-10-08T11:00:00Z"));
2319 assert!(listed.iter().all(|p| p.password.is_none()));
2320
2321 let none = response(r#"{"subsonic-response": {"status": "ok", "appPasswords": {}}}"#);
2322 assert!(none.app_passwords.unwrap().app_password.is_empty());
2323
2324 let made = response(
2325 r#"{"subsonic-response": {"status": "ok", "appPassword": {"id": 5, "name": "Feishin", "password": "s3cret"}}}"#,
2326 );
2327 let made = made.app_password.unwrap();
2328 assert_eq!((made.id, made.password.as_deref()), (5, Some("s3cret")));
2329 }
2330
2331 #[test]
2332 fn test_deserialize_scan_status_count() {
2333 let json = r#"{"subsonic-response":{"status":"ok","scanStatus":{"scanning":false,"count":49700}}}"#;
2334 let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
2335 let status = wrapper.subsonic_response.scan_status.unwrap();
2336 assert_eq!(status.count, Some(49_700));
2337 }
2338
2339 #[test]
2340 fn test_deserialize_search_result_defaults_to_empty() {
2341 let result: SubsonicSearchResult = serde_json::from_str("{}").unwrap();
2343
2344 assert!(result.artist.is_empty());
2345 assert!(result.album.is_empty());
2346 assert!(result.song.is_empty());
2347 }
2348}