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 #[serde(default, deserialize_with = "null_as_empty")]
1725 pub artists: Vec<SubsonicArtistRef>,
1726 #[serde(default, deserialize_with = "null_as_empty")]
1727 pub album_artists: Vec<SubsonicArtistRef>,
1728}
1729
1730#[derive(Debug, Clone, Deserialize)]
1732#[serde(rename_all = "camelCase")]
1733pub struct SubsonicArtistRef {
1734 #[serde(default, deserialize_with = "non_empty")]
1735 pub id: Option<String>,
1736 #[serde(default, deserialize_with = "non_empty")]
1737 pub name: Option<String>,
1738 #[serde(default, deserialize_with = "non_empty")]
1739 pub music_brainz_id: Option<String>,
1740}
1741
1742impl SubsonicSong {
1743 pub fn credits(&self) -> crate::db::queries::Credits {
1745 let list = |refs: &[SubsonicArtistRef]| {
1746 refs.iter()
1747 .filter_map(|r| Some((r, r.name.as_deref()?.trim())))
1748 .filter(|(_, name)| !name.is_empty())
1749 .map(|(r, name)| crate::db::queries::Credit {
1750 name: name.to_string(),
1751 mbid: r.music_brainz_id.clone(),
1752 remote_id: r.id.clone(),
1753 })
1754 .collect()
1755 };
1756 crate::db::queries::Credits {
1757 artists: list(&self.artists),
1758 album_artists: list(&self.album_artists),
1759 }
1760 }
1761}
1762
1763#[derive(Debug, Deserialize)]
1764struct SubsonicAlbumList {
1765 #[serde(default)]
1766 album: Vec<SubsonicAlbum>,
1767}
1768
1769#[derive(Debug, Default, Deserialize)]
1770pub struct SubsonicSearchResult {
1771 #[serde(default)]
1772 pub artist: Vec<SubsonicArtist>,
1773 #[serde(default)]
1774 pub album: Vec<SubsonicAlbum>,
1775 #[serde(default)]
1776 pub song: Vec<SubsonicSong>,
1777}
1778
1779#[derive(Debug, Default, Deserialize)]
1780pub struct SubsonicStarred {
1781 #[serde(default)]
1782 pub song: Vec<SubsonicSong>,
1783 #[serde(default)]
1784 pub album: Vec<SubsonicAlbum>,
1785 #[serde(default)]
1786 pub artist: Vec<SubsonicArtist>,
1787}
1788
1789#[derive(Debug, Deserialize)]
1790pub struct SubsonicSimilarSongs {
1791 pub song: Option<Vec<SubsonicSong>>,
1792}
1793
1794#[derive(Debug, Default, Deserialize)]
1795struct SubsonicPlaylists {
1796 #[serde(default)]
1797 playlist: Vec<SubsonicPlaylist>,
1798}
1799
1800#[derive(Debug, Clone, Deserialize)]
1802#[serde(rename_all = "camelCase")]
1803pub struct SubsonicPlaylist {
1804 pub id: String,
1805 pub name: String,
1806 pub comment: Option<String>,
1807 pub owner: Option<String>,
1808 #[serde(default)]
1809 pub public: bool,
1810 pub song_count: Option<i64>,
1811 pub duration: Option<i64>,
1812 pub created: Option<String>,
1813 pub changed: Option<String>,
1814 #[serde(default)]
1817 pub readonly: bool,
1818}
1819
1820#[derive(Debug, Clone, Deserialize)]
1821#[serde(rename_all = "camelCase")]
1822pub struct SubsonicPlaylistFull {
1823 #[serde(flatten)]
1824 pub playlist: SubsonicPlaylist,
1825 #[serde(default)]
1826 pub entry: Vec<SubsonicSong>,
1827}
1828
1829#[derive(Debug, Deserialize)]
1830struct SubsonicShares {
1831 #[serde(default)]
1832 share: Vec<SubsonicShare>,
1833}
1834
1835#[derive(Debug, Clone, Deserialize)]
1836#[serde(rename_all = "camelCase")]
1837pub struct SubsonicShare {
1838 pub id: String,
1839 pub url: Option<String>,
1840 pub description: Option<String>,
1841 pub username: Option<String>,
1842 pub created: Option<String>,
1843 pub expires: Option<String>,
1844 pub visit_count: Option<i64>,
1845}
1846
1847fn non_empty<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
1854 Ok(Option::<String>::deserialize(d)?.filter(|s| !s.is_empty()))
1855}
1856
1857fn null_as_empty<'de, D, T>(d: D) -> Result<Vec<T>, D::Error>
1858where
1859 D: serde::Deserializer<'de>,
1860 T: Deserialize<'de>,
1861{
1862 Ok(Option::<Vec<T>>::deserialize(d)?.unwrap_or_default())
1863}
1864
1865fn random_salt() -> Result<String, getrandom::Error> {
1872 let mut buf = [0u8; 12];
1873 getrandom::fill(&mut buf)?;
1874 Ok(buf.iter().map(|b| format!("{:02x}", b)).collect())
1875}
1876
1877#[cfg(test)]
1878mod tests {
1879 #[test]
1880 fn a_song_with_null_or_nameless_credits_still_reads() {
1881 let song: SubsonicSong = serde_json::from_value(serde_json::json!({
1882 "id": "s1",
1883 "title": "Siilent",
1884 "artists": null,
1885 "albumArtists": [
1886 {"id": "ar1"},
1887 {"id": "ar2", "name": "Frànçois & the Atlas Mountains", "musicBrainzId": "63862e0f"}
1888 ]
1889 }))
1890 .unwrap();
1891 let credits = song.credits();
1892 assert!(credits.artists.is_empty());
1893 assert_eq!(credits.album_artists.len(), 1);
1894 assert_eq!(
1895 credits.album_artists[0].name,
1896 "Frànçois & the Atlas Mountains"
1897 );
1898 assert_eq!(credits.album_artists[0].remote_id.as_deref(), Some("ar2"));
1899 }
1900
1901 use std::time::{Duration, Instant};
1902
1903 use super::*;
1904
1905 #[test]
1906 fn a_network_change_tries_a_server_that_was_not_answering_now() {
1907 let client = SubsonicClient::new("http://192.168.1.10:4533", "koan", "pw");
1908 client.outage().down(
1909 Some(Duration::from_secs(60)),
1910 "Can't reach the server".into(),
1911 );
1912
1913 let started = Instant::now();
1914 std::thread::scope(|scope| {
1915 scope.spawn(|| {
1916 std::thread::sleep(Duration::from_millis(100));
1917 client.renew();
1918 });
1919 client.outage().hold();
1920 });
1921 assert!(
1922 started.elapsed() < Duration::from_secs(5),
1923 "renewed: the next try is due now, not in a minute"
1924 );
1925 assert_eq!(
1926 client.outage().reason().as_deref(),
1927 Some("Can't reach the server"),
1928 "still down until a try answers"
1929 );
1930 }
1931
1932 #[test]
1933 fn a_saved_play_queue_reads_by_index_or_by_id() {
1934 let parse = |json: &str| {
1935 serde_json::from_str::<SubsonicResponseWrapper>(json)
1936 .unwrap()
1937 .subsonic_response
1938 };
1939 let entries = r#"[{"id":"a","title":"A"},{"id":"b","title":"B"},{"id":"a","title":"A"}]"#;
1940 let by_index = parse(&format!(
1941 r#"{{"subsonic-response":{{"status":"ok","playQueueByIndex":{{"entry":{entries},"currentIndex":2,"position":1500,"changedBy":"koan d1"}}}}}}"#
1942 ));
1943 let q = by_index.play_queue_by_index.unwrap();
1944 assert_eq!(
1945 (q.current_at(), q.position, q.changed_by.as_str()),
1946 (Some(2), 1500, "koan d1")
1947 );
1948
1949 let by_id = parse(&format!(
1951 r#"{{"subsonic-response":{{"status":"ok","playQueue":{{"entry":{entries},"current":"a"}}}}}}"#
1952 ));
1953 assert_eq!(by_id.play_queue.unwrap().current_at(), Some(0));
1954 let gone = parse(&format!(
1955 r#"{{"subsonic-response":{{"status":"ok","playQueue":{{"entry":{entries},"current":"z"}}}}}}"#
1956 ));
1957 assert_eq!(gone.play_queue.unwrap().current_at(), None);
1958 }
1959
1960 fn response(json: &str) -> SubsonicResponse {
1961 serde_json::from_str::<SubsonicResponseWrapper>(json)
1962 .unwrap()
1963 .subsonic_response
1964 }
1965
1966 #[test]
1967 fn http_errors_leave_the_signed_url_out() {
1968 let e = reqwest::blocking::get("http://127.0.0.1:1/rest/stream?u=owner&p=enc:736563726574")
1970 .unwrap_err();
1971 assert!(e.to_string().contains("enc:736563726574"));
1972 let shown = SubsonicError::from(e).to_string();
1973 assert!(!shown.contains("enc:"), "{shown}");
1974 let e = reqwest::blocking::get("http://127.0.0.1:1/rest/stream?p=enc:736563726574")
1975 .unwrap_err();
1976 assert!(!DownloadError::from(e).to_string().contains("enc:"));
1977 }
1978
1979 #[test]
1980 fn playback_report_is_read_from_the_advertised_extensions() {
1981 let navidrome = response(
1982 r#"{"subsonic-response": {
1983 "status": "ok", "version": "1.16.1", "type": "navidrome",
1984 "openSubsonic": true,
1985 "openSubsonicExtensions": [
1986 {"name": "transcodeOffset", "versions": [1]},
1987 {"name": "playbackReport", "versions": [1]}
1988 ]
1989 }}"#,
1990 );
1991 assert!(navidrome.has_extension(PLAYBACK_REPORT_EXTENSION));
1992
1993 let without = response(
1994 r#"{"subsonic-response": {
1995 "status": "ok", "version": "1.16.1",
1996 "openSubsonicExtensions": [{"name": "songLyrics", "versions": [1, 2]}]
1997 }}"#,
1998 );
1999 assert!(!without.has_extension(PLAYBACK_REPORT_EXTENSION));
2000
2001 let plain = response(r#"{"subsonic-response": {"status": "ok", "version": "1.16.1"}}"#);
2002 assert!(!plain.has_extension(PLAYBACK_REPORT_EXTENSION));
2003 }
2004
2005 #[test]
2008 fn test_deserialize_subsonic_song() {
2009 let json = r#"{
2010 "id": "42",
2011 "title": "Space Oddity",
2012 "album": "Space Oddity",
2013 "artist": "David Bowie",
2014 "track": 1,
2015 "discNumber": 1,
2016 "year": 1969,
2017 "genre": "Rock",
2018 "duration": 314,
2019 "bitRate": 320,
2020 "suffix": "mp3",
2021 "contentType": "audio/mpeg",
2022 "albumId": "7",
2023 "artistId": "3"
2024 }"#;
2025
2026 let song: SubsonicSong = serde_json::from_str(json).unwrap();
2027
2028 assert_eq!(song.id, "42");
2029 assert_eq!(song.title, "Space Oddity");
2030 assert_eq!(song.album.as_deref(), Some("Space Oddity"));
2031 assert_eq!(song.artist.as_deref(), Some("David Bowie"));
2032 assert_eq!(song.track, Some(1));
2033 assert_eq!(song.disc_number, Some(1));
2034 assert_eq!(song.year, Some(1969));
2035 assert_eq!(song.genre.as_deref(), Some("Rock"));
2036 assert_eq!(song.duration, Some(314));
2037 assert_eq!(song.bit_rate, Some(320));
2038 assert_eq!(song.suffix.as_deref(), Some("mp3"));
2039 assert_eq!(song.content_type.as_deref(), Some("audio/mpeg"));
2040 assert_eq!(song.album_id.as_deref(), Some("7"));
2041 assert_eq!(song.artist_id.as_deref(), Some("3"));
2042 }
2043
2044 #[test]
2048 fn opensubsonic_quality_fields_are_read() {
2049 let json = r#"{
2050 "id": "000XtGC7jsWEbOjDsZi4Xw",
2051 "title": "Anguish",
2052 "suffix": "flac",
2053 "bitRate": 913,
2054 "samplingRate": 44100,
2055 "bitDepth": 16,
2056 "channelCount": 2
2057 }"#;
2058
2059 let song: SubsonicSong = serde_json::from_str(json).unwrap();
2060
2061 assert_eq!(song.sampling_rate, Some(44100));
2062 assert_eq!(song.bit_depth, Some(16));
2063 assert_eq!(song.channel_count, Some(2));
2064 }
2065
2066 #[test]
2069 fn a_koan_server_names_the_codec() {
2070 let json = r#"{"id": "1", "title": "Seawhite", "suffix": "m4a", "codec": "ALAC"}"#;
2071 let song: SubsonicSong = serde_json::from_str(json).unwrap();
2072 assert_eq!(song.codec.as_deref(), Some("ALAC"));
2073
2074 let json = r#"{"id": "1", "title": "Seawhite", "suffix": "m4a"}"#;
2075 let song: SubsonicSong = serde_json::from_str(json).unwrap();
2076 assert_eq!(song.codec, None);
2077 }
2078
2079 #[test]
2082 fn a_plain_subsonic_song_has_no_quality_figures() {
2083 let json = r#"{"id": "1", "title": "Track", "bitRate": 320}"#;
2084 let song: SubsonicSong = serde_json::from_str(json).unwrap();
2085
2086 assert_eq!(song.sampling_rate, None);
2087 assert_eq!(song.bit_depth, None);
2088 assert_eq!(song.channel_count, None);
2089 }
2090
2091 #[test]
2092 fn test_deserialize_subsonic_song_optional_fields_absent() {
2093 let json = r#"{"id": "99", "title": "Minimal Track"}"#;
2095
2096 let song: SubsonicSong = serde_json::from_str(json).unwrap();
2097
2098 assert_eq!(song.id, "99");
2099 assert_eq!(song.title, "Minimal Track");
2100 assert!(song.album.is_none());
2101 assert!(song.artist.is_none());
2102 assert!(song.track.is_none());
2103 assert!(song.disc_number.is_none());
2104 assert!(song.year.is_none());
2105 assert!(song.duration.is_none());
2106 assert!(song.bit_rate.is_none());
2107 }
2108
2109 #[test]
2112 fn test_deserialize_album_list() {
2113 let json = r#"{
2114 "subsonic-response": {
2115 "status": "ok",
2116 "version": "1.16.1",
2117 "albumList2": {
2118 "album": [
2119 {
2120 "id": "1",
2121 "name": "Abbey Road",
2122 "artist": "The Beatles",
2123 "artistId": "10",
2124 "songCount": 17,
2125 "year": 1969,
2126 "genre": "Rock",
2127 "created": "2020-01-01T00:00:00"
2128 },
2129 {
2130 "id": "2",
2131 "name": "Led Zeppelin IV",
2132 "artist": "Led Zeppelin",
2133 "artistId": "11",
2134 "songCount": 8,
2135 "year": 1971,
2136 "genre": "Hard Rock",
2137 "created": "2020-01-02T00:00:00"
2138 }
2139 ]
2140 }
2141 }
2142 }"#;
2143
2144 let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
2145 let album_list = wrapper
2146 .subsonic_response
2147 .album_list2
2148 .expect("album_list2 should be present");
2149
2150 assert_eq!(album_list.album.len(), 2);
2151
2152 let first = &album_list.album[0];
2153 assert_eq!(first.id, "1");
2154 assert_eq!(first.name, "Abbey Road");
2155 assert_eq!(first.artist.as_deref(), Some("The Beatles"));
2156 assert_eq!(first.artist_id.as_deref(), Some("10"));
2157 assert_eq!(first.song_count, Some(17));
2158 assert_eq!(first.year, Some(1969));
2159
2160 let second = &album_list.album[1];
2161 assert_eq!(second.id, "2");
2162 assert_eq!(second.name, "Led Zeppelin IV");
2163 assert_eq!(second.song_count, Some(8));
2164 }
2165
2166 #[test]
2169 fn test_auth_params_format() {
2170 let client = SubsonicClient::new("http://localhost:4533", "alice", "secret");
2171 let params = client.auth_params().unwrap();
2172
2173 assert!(params.contains_key("u"), "missing 'u' param");
2175 assert!(params.contains_key("t"), "missing 't' param");
2176 assert!(params.contains_key("s"), "missing 's' param");
2177 assert!(params.contains_key("v"), "missing 'v' param");
2178 assert!(params.contains_key("c"), "missing 'c' param");
2179 assert!(params.contains_key("f"), "missing 'f' param");
2180 assert_eq!(params.len(), 6);
2181
2182 assert_eq!(params["u"], "alice");
2183 assert_eq!(params["v"], "1.16.1");
2184 assert_eq!(params["c"], "koan");
2185 assert_eq!(params["f"], "json");
2186 }
2187
2188 #[test]
2189 fn test_auth_params_over_https_send_the_hex_password() {
2190 let client = SubsonicClient::new("https://koan.example", "alice", "hi");
2191 let params = client.auth_params().unwrap();
2192 assert_eq!(params["p"], "enc:6869");
2193 assert!(!params.contains_key("t") && !params.contains_key("s"));
2194 }
2195
2196 #[test]
2197 fn test_auth_params_with_an_api_key_name_no_user() {
2198 let auth = SubsonicAuth::with(
2199 "http://koan.example",
2200 "alice",
2201 Credential::ApiKey("k3y".into()),
2202 );
2203 let params = auth.params().unwrap();
2204 assert_eq!(params.get("apiKey").map(String::as_str), Some("k3y"));
2205 for absent in ["u", "p", "t", "s"] {
2206 assert!(!params.contains_key(absent), "{absent}");
2207 }
2208 }
2209
2210 #[test]
2211 fn test_auth_params_token_is_md5_of_password_plus_salt() {
2212 let client = SubsonicClient::new("http://localhost:4533", "bob", "letmein");
2213 let params = client.auth_params().unwrap();
2214
2215 let salt = ¶ms["s"];
2216 let token = ¶ms["t"];
2217
2218 let expected = format!("{:x}", md5::compute(format!("letmein{}", salt)));
2220 assert_eq!(token, &expected);
2221 }
2222
2223 #[test]
2224 fn test_auth_params_salt_is_different_each_call() {
2225 let client = SubsonicClient::new("http://localhost:4533", "user", "pass");
2226 let params1 = client.auth_params().unwrap();
2227 let params2 = client.auth_params().unwrap();
2228
2229 assert_ne!(params1["s"], params2["s"], "salt should be random per call");
2232 }
2233
2234 #[test]
2237 fn test_stream_url_has_auth() {
2238 let client = SubsonicClient::new("http://myserver:4533", "user", "pass");
2239 let url = client.stream_url("track-123").unwrap();
2240
2241 assert!(url.contains("track-123"), "url must include the track id");
2242 assert!(url.contains("u=user"), "url must include username param");
2243 assert!(url.contains("v=1.16.1"), "url must include api version");
2244 assert!(url.contains("c=koan"), "url must include client name");
2245 assert!(url.contains("f=json"), "url must include format param");
2246 assert!(url.contains("/rest/stream"), "url must target /rest/stream");
2247 assert!(
2248 url.starts_with("http://myserver:4533"),
2249 "url must use the configured base_url"
2250 );
2251 }
2252
2253 #[test]
2254 fn test_stream_url_base_url_trailing_slash_normalised() {
2255 let client_with_slash = SubsonicClient::new("http://myserver:4533/", "u", "p");
2257 let client_no_slash = SubsonicClient::new("http://myserver:4533", "u", "p");
2258
2259 let url_with = client_with_slash.stream_url("1").unwrap();
2260 let url_without = client_no_slash.stream_url("1").unwrap();
2261
2262 assert!(
2264 url_with.contains("/rest/stream"),
2265 "should not have double slash"
2266 );
2267 assert!(!url_with.contains("//rest"), "should not have double slash");
2268 assert_eq!(
2270 url_with.split('?').next(),
2271 url_without.split('?').next(),
2272 "path segment should be identical regardless of trailing slash"
2273 );
2274 }
2275
2276 #[test]
2279 fn test_deserialize_album_full_with_songs() {
2280 let json = r#"{
2281 "id": "5",
2282 "name": "Kind of Blue",
2283 "artist": "Miles Davis",
2284 "artistId": "20",
2285 "year": 1959,
2286 "genre": "Jazz",
2287 "songCount": 5,
2288 "created": "2021-06-01T00:00:00",
2289 "song": [
2290 {"id": "101", "title": "So What"},
2291 {"id": "102", "title": "Freddie Freeloader"},
2292 {"id": "103", "title": "Blue in Green"}
2293 ]
2294 }"#;
2295
2296 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
2297
2298 assert_eq!(album.id, "5");
2299 assert_eq!(album.name, "Kind of Blue");
2300 assert_eq!(album.artist.as_deref(), Some("Miles Davis"));
2301 assert_eq!(album.year, Some(1959));
2302 assert_eq!(album.song.len(), 3);
2303 assert_eq!(album.song[0].title, "So What");
2304 assert_eq!(album.song[2].id, "103");
2305 }
2306
2307 #[test]
2308 fn test_deserialize_album_full_empty_song_list() {
2309 let json = r#"{"id": "9", "name": "No Tracks Yet"}"#;
2311
2312 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
2313
2314 assert_eq!(album.id, "9");
2315 assert!(album.song.is_empty(), "song list should default to empty");
2316 }
2317
2318 #[test]
2321 fn test_deserialize_search_result_mixed() {
2322 let json = r#"{
2323 "artist": [{"id": "1", "name": "Artist One"}],
2324 "album": [{"id": "2", "name": "Album One"}],
2325 "song": [{"id": "3", "title": "Song One"}]
2326 }"#;
2327
2328 let result: SubsonicSearchResult = serde_json::from_str(json).unwrap();
2329
2330 assert_eq!(result.artist.len(), 1);
2331 assert_eq!(result.artist[0].name, "Artist One");
2332 assert_eq!(result.album.len(), 1);
2333 assert_eq!(result.album[0].name, "Album One");
2334 assert_eq!(result.song.len(), 1);
2335 assert_eq!(result.song[0].title, "Song One");
2336 }
2337
2338 #[test]
2339 fn empty_opensubsonic_ids_are_absent() {
2340 let json = r#"{"id": "1", "title": "T", "musicBrainzId": ""}"#;
2341 let song: SubsonicSong = serde_json::from_str(json).unwrap();
2342 assert_eq!(song.music_brainz_id, None);
2343
2344 let json = r#"{"id": "2", "name": "A", "musicBrainzId": "", "sortName": ""}"#;
2345 let album: SubsonicAlbum = serde_json::from_str(json).unwrap();
2346 assert_eq!((album.music_brainz_id, album.sort_name), (None, None));
2347
2348 let json = r#"{"id": "3", "name": "A", "musicBrainzId": "mb-1"}"#;
2349 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
2350 assert_eq!(album.music_brainz_id.as_deref(), Some("mb-1"));
2351 assert_eq!(album.sort_name, None);
2352 }
2353
2354 #[test]
2355 fn deleted_dsp_profiles_read_with_their_times() {
2356 let listed = response(
2357 r#"{"subsonic-response": {"status": "ok", "koanDspDeleted": {"profile": [
2358 {"uid": "u1", "name": "Room", "deletedAt": 1000, "expiresAt": 2592001000}
2359 ]}}}"#,
2360 );
2361 let listed = listed.koan_dsp_deleted.unwrap().profile;
2362 assert_eq!(listed.len(), 1);
2363 assert_eq!(
2364 (listed[0].uid.as_str(), listed[0].name.as_str()),
2365 ("u1", "Room")
2366 );
2367 assert_eq!(
2368 (listed[0].deleted_at, listed[0].expires_at),
2369 (1000, 2_592_001_000)
2370 );
2371 let none = response(r#"{"subsonic-response": {"status": "ok", "koanDspDeleted": {}}}"#);
2372 assert!(none.koan_dsp_deleted.unwrap().profile.is_empty());
2373 }
2374
2375 #[test]
2376 fn app_passwords_read_listed_and_made() {
2377 let listed = response(
2378 r#"{"subsonic-response": {"status": "ok", "appPasswords": {"appPassword": [
2379 {"id": 3, "name": "Arpeggi", "created": "2026-10-08T09:00:00Z"},
2380 {"id": 4, "name": "Tempo", "created": "2026-10-08T10:00:00Z", "lastUsed": "2026-10-08T11:00:00Z"}
2381 ]}}}"#,
2382 );
2383 let listed = listed.app_passwords.unwrap().app_password;
2384 assert_eq!(listed.len(), 2);
2385 assert_eq!((listed[0].id, listed[0].name.as_str()), (3, "Arpeggi"));
2386 assert_eq!(listed[0].last_used, None);
2387 assert_eq!(listed[1].last_used.as_deref(), Some("2026-10-08T11:00:00Z"));
2388 assert!(listed.iter().all(|p| p.password.is_none()));
2389
2390 let none = response(r#"{"subsonic-response": {"status": "ok", "appPasswords": {}}}"#);
2391 assert!(none.app_passwords.unwrap().app_password.is_empty());
2392
2393 let made = response(
2394 r#"{"subsonic-response": {"status": "ok", "appPassword": {"id": 5, "name": "Feishin", "password": "s3cret"}}}"#,
2395 );
2396 let made = made.app_password.unwrap();
2397 assert_eq!((made.id, made.password.as_deref()), (5, Some("s3cret")));
2398 }
2399
2400 #[test]
2401 fn test_deserialize_scan_status_count() {
2402 let json = r#"{"subsonic-response":{"status":"ok","scanStatus":{"scanning":false,"count":49700}}}"#;
2403 let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
2404 let status = wrapper.subsonic_response.scan_status.unwrap();
2405 assert_eq!(status.count, Some(49_700));
2406 }
2407
2408 #[test]
2409 fn test_deserialize_search_result_defaults_to_empty() {
2410 let result: SubsonicSearchResult = serde_json::from_str("{}").unwrap();
2412
2413 assert!(result.artist.is_empty());
2414 assert!(result.album.is_empty());
2415 assert!(result.song.is_empty());
2416 }
2417}