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(#[from] 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
30#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct SubsonicAuth {
38 pub base_url: String,
39 pub username: String,
40 pub password: String,
41}
42
43impl SubsonicAuth {
44 pub fn new(base_url: &str, username: &str, password: &str) -> Self {
45 Self {
46 base_url: base_url.trim_end_matches('/').to_string(),
47 username: username.to_string(),
48 password: password.to_string(),
49 }
50 }
51
52 fn params(&self) -> Result<HashMap<String, String>, SubsonicError> {
59 let mut params = HashMap::new();
60 params.insert("u".into(), self.username.clone());
61 if self.base_url.starts_with("https://") {
62 let hex: String = self.password.bytes().map(|b| format!("{b:02x}")).collect();
63 params.insert("p".into(), format!("enc:{hex}"));
64 } else {
65 let salt = random_salt()?;
66 let token = format!("{:x}", md5::compute(format!("{}{}", self.password, salt)));
67 params.insert("t".into(), token);
68 params.insert("s".into(), salt);
69 }
70 params.insert("v".into(), API_VERSION.into());
71 params.insert("c".into(), CLIENT_NAME.into());
72 params.insert("f".into(), "json".into());
73 Ok(params)
74 }
75
76 pub fn query(&self) -> Result<String, SubsonicError> {
78 Ok(self
79 .params()?
80 .iter()
81 .map(|(k, v)| format!("{}={}", k, v))
82 .collect::<Vec<_>>()
83 .join("&"))
84 }
85
86 pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
88 Ok(format!(
89 "{}/rest/stream?id={}&{}",
90 self.base_url,
91 track_id,
92 self.query()?
93 ))
94 }
95}
96
97pub struct SubsonicClient {
104 auth: SubsonicAuth,
105 http: reqwest::blocking::Client,
106 downloader: reqwest::blocking::Client,
107 outage: download::Outage,
109 playback_report: OnceLock<bool>,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum PlaybackReportState {
116 Playing,
117 Paused,
118 Stopped,
119}
120
121impl PlaybackReportState {
122 fn as_str(self) -> &'static str {
123 match self {
124 Self::Playing => "playing",
125 Self::Paused => "paused",
126 Self::Stopped => "stopped",
127 }
128 }
129}
130
131impl SubsonicClient {
132 pub fn new(base_url: &str, username: &str, password: &str) -> Self {
133 Self::from_auth(SubsonicAuth::new(base_url, username, password))
134 }
135
136 pub fn from_auth(auth: SubsonicAuth) -> Self {
137 Self {
138 auth,
139 http: download::api_client().unwrap_or_else(|e| {
140 log::warn!("falling back to default HTTP client: {}", e);
141 reqwest::blocking::Client::new()
142 }),
143 downloader: download::download_client().unwrap_or_else(|e| {
144 log::warn!("falling back to default download client: {}", e);
145 reqwest::blocking::Client::new()
146 }),
147 outage: download::Outage::default(),
148 playback_report: OnceLock::new(),
149 }
150 }
151
152 pub fn outage(&self) -> &download::Outage {
154 &self.outage
155 }
156
157 fn auth_params(&self) -> Result<HashMap<String, String>, SubsonicError> {
158 self.auth.params()
159 }
160
161 fn get(&self, endpoint: &str) -> Result<SubsonicResponse, SubsonicError> {
163 self.get_with_params(endpoint, &[])
164 }
165
166 fn get_with_params(
167 &self,
168 endpoint: &str,
169 extra: &[(&str, &str)],
170 ) -> Result<SubsonicResponse, SubsonicError> {
171 let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
172 let mut params = self.auth_params()?;
173 for (k, v) in extra {
174 params.insert((*k).to_string(), (*v).to_string());
175 }
176
177 let resp: SubsonicResponseWrapper = self.http.get(&url).query(¶ms).send()?.json()?;
178
179 let inner = resp.subsonic_response;
180 if inner.status != "ok" {
181 if let Some(err) = inner.error {
182 return Err(SubsonicError::Api {
183 code: err.code,
184 message: err.message,
185 });
186 }
187 return Err(SubsonicError::BadResponse);
188 }
189
190 Ok(inner)
191 }
192
193 fn reject_error_body(resp: &reqwest::blocking::Response) -> Result<(), SubsonicError> {
200 let is_document = resp
201 .headers()
202 .get(reqwest::header::CONTENT_TYPE)
203 .and_then(|v| v.to_str().ok())
204 .is_some_and(|ct| ct.contains("json") || ct.contains("xml"));
205 if is_document {
206 return Err(SubsonicError::BadResponse);
207 }
208 if !resp.status().is_success() {
209 return Err(SubsonicError::BadResponse);
210 }
211 Ok(())
212 }
213
214 pub fn get_cover_art(&self, id: &str, size: Option<u32>) -> Result<Vec<u8>, SubsonicError> {
220 let url = format!("{}/rest/getCoverArt", self.base_url());
221 let mut params = self.auth_params()?;
222 params.insert("id".into(), id.to_string());
223 if let Some(px) = size {
224 params.insert("size".into(), px.to_string());
225 }
226
227 let resp = self.http.get(&url).query(¶ms).send()?;
228 Self::reject_error_body(&resp)?;
229 Ok(resp.bytes()?.to_vec())
230 }
231
232 pub fn ping(&self) -> Result<(), SubsonicError> {
234 self.get("ping")?;
235 Ok(())
236 }
237
238 pub fn server_type(&self) -> Result<Option<String>, SubsonicError> {
240 Ok(self.get("ping")?.server_type)
241 }
242
243 pub fn profile(&self) -> Result<crate::remote::profile::ServerProfile, SubsonicError> {
247 let ping = self.get("ping")?;
248 let extensions = if ping.open_subsonic {
249 self.get("getOpenSubsonicExtensions")
250 .ok()
251 .and_then(|r| r.open_subsonic_extensions)
252 .unwrap_or_default()
253 .into_iter()
254 .map(|e| (e.name, e.versions))
255 .collect()
256 } else {
257 Vec::new()
258 };
259 Ok(crate::remote::profile::ServerProfile {
260 kind: ping.server_type,
261 version: ping.server_version,
262 open_subsonic: ping.open_subsonic,
263 extensions,
264 })
265 }
266
267 pub fn get_artists(&self) -> Result<Vec<SubsonicArtist>, SubsonicError> {
269 let resp = self.get("getArtists")?;
270 let artists_data = resp.artists.ok_or(SubsonicError::BadResponse)?;
271 let mut all = Vec::new();
272 for index in artists_data.index {
273 all.extend(index.artist);
274 }
275 Ok(all)
276 }
277
278 pub fn get_album(&self, id: &str) -> Result<SubsonicAlbumFull, SubsonicError> {
280 let resp = self.get_with_params("getAlbum", &[("id", id)])?;
281 resp.album.ok_or(SubsonicError::BadResponse)
282 }
283
284 pub fn get_album_list(
286 &self,
287 list_type: &str,
288 size: u32,
289 offset: u32,
290 ) -> Result<Vec<SubsonicAlbum>, SubsonicError> {
291 let size_str = size.to_string();
292 let offset_str = offset.to_string();
293 let resp = self.get_with_params(
294 "getAlbumList2",
295 &[
296 ("type", list_type),
297 ("size", &size_str),
298 ("offset", &offset_str),
299 ],
300 )?;
301 Ok(resp.album_list2.map(|al| al.album).unwrap_or_default())
302 }
303
304 pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
306 self.auth.stream_url(track_id)
307 }
308
309 pub fn stream_url_template(&self, track_id: &str) -> String {
311 format!("{}/rest/stream?id={}", self.auth.base_url, track_id)
312 }
313
314 pub fn download(&self, track_id: &str, dest: &Path) -> Result<(), SubsonicError> {
316 self.fetch_to_file("download", track_id, dest, None, |_, _| {})
317 }
318
319 pub fn download_with_progress(
328 &self,
329 track_id: &str,
330 dest: &Path,
331 cancelled: &dyn Fn() -> bool,
332 on_progress: impl Fn(u64, u64),
333 ) -> Result<(), SubsonicError> {
334 let patience = download::Patience {
335 outage: &self.outage,
336 cancelled,
337 };
338 self.fetch_to_file("download", track_id, dest, Some(patience), on_progress)
339 }
340
341 pub fn stream_to_file(
347 &self,
348 track_id: &str,
349 dest: &Path,
350 on_progress: impl Fn(u64, u64),
351 ) -> Result<(), SubsonicError> {
352 self.fetch_to_file("stream", track_id, dest, None, on_progress)
353 }
354
355 fn fetch_to_file(
356 &self,
357 endpoint: &str,
358 track_id: &str,
359 dest: &Path,
360 patience: Option<download::Patience<'_>>,
361 on_progress: impl Fn(u64, u64),
362 ) -> Result<(), SubsonicError> {
363 let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
364 download::download_with_retries(
365 dest,
366 download::DEFAULT_ATTEMPTS,
367 patience,
368 || {
369 let mut params = self
371 .auth_params()
372 .map_err(|e| download::DownloadError::Request(e.to_string()))?;
373 params.insert("id".into(), track_id.to_string());
374 Ok(self.downloader.get(&url).query(¶ms))
375 },
376 on_progress,
377 )?;
378 Ok(())
379 }
380
381 pub fn all_songs_page(
387 &self,
388 size: u32,
389 offset: u32,
390 ) -> Result<Vec<SubsonicSong>, SubsonicError> {
391 let size = size.to_string();
392 let offset = offset.to_string();
393 let resp = self.get_with_params(
394 "search3",
395 &[
396 ("query", ""),
397 ("artistCount", "0"),
398 ("albumCount", "0"),
399 ("songCount", &size),
400 ("songOffset", &offset),
401 ],
402 )?;
403 Ok(resp.search_result3.map(|r| r.song).unwrap_or_default())
404 }
405
406 pub fn song_count(&self) -> Result<Option<u64>, SubsonicError> {
409 let resp = self.get("getScanStatus")?;
410 Ok(resp.scan_status.and_then(|s| s.count))
411 }
412
413 pub fn search(&self, query: &str) -> Result<SubsonicSearchResult, SubsonicError> {
415 let resp = self.get_with_params("search3", &[("query", query)])?;
416 Ok(resp.search_result3.unwrap_or_default())
417 }
418
419 pub fn scrobble(&self, track_id: &str, heard_at_ms: u64) -> Result<(), SubsonicError> {
422 let at = heard_at_ms.to_string();
423 self.get_with_params(
424 "scrobble",
425 &[("id", track_id), ("submission", "true"), ("time", &at)],
426 )?;
427 Ok(())
428 }
429
430 pub fn report_playback(
439 &self,
440 track_id: &str,
441 state: PlaybackReportState,
442 position_ms: u64,
443 ) -> Result<(), SubsonicError> {
444 if self.supports_playback_report() {
445 let position = position_ms.to_string();
446 self.get_with_params(
447 "reportPlayback",
448 &[
449 ("mediaId", track_id),
450 ("mediaType", "song"),
451 ("positionMs", &position),
452 ("state", state.as_str()),
453 ("ignoreScrobble", "true"),
454 ],
455 )?;
456 return Ok(());
457 }
458 if state == PlaybackReportState::Playing {
459 let position = (position_ms / 1000).to_string();
460 self.get_with_params(
461 "scrobble",
462 &[
463 ("id", track_id),
464 ("submission", "false"),
465 ("position", &position),
466 ],
467 )?;
468 }
469 Ok(())
470 }
471
472 fn supports_playback_report(&self) -> bool {
476 if let Some(&known) = self.playback_report.get() {
477 return known;
478 }
479 let supported = match self.get("getOpenSubsonicExtensions") {
480 Ok(resp) => resp.has_extension(PLAYBACK_REPORT_EXTENSION),
481 Err(SubsonicError::Http(e)) if e.is_connect() || e.is_timeout() => return false,
482 Err(_) => false,
483 };
484 *self.playback_report.get_or_init(|| supported)
485 }
486
487 pub fn star(&self, track_id: &str) -> Result<(), SubsonicError> {
489 self.get_with_params("star", &[("id", track_id)])?;
490 Ok(())
491 }
492
493 pub fn unstar(&self, track_id: &str) -> Result<(), SubsonicError> {
495 self.get_with_params("unstar", &[("id", track_id)])?;
496 Ok(())
497 }
498
499 pub fn get_starred(&self) -> Result<Vec<SubsonicSong>, SubsonicError> {
501 let resp = self.get("getStarred2")?;
502 Ok(resp.starred2.map(|s| s.song).unwrap_or_default())
503 }
504
505 pub fn get_starred_all(&self) -> Result<SubsonicStarred, SubsonicError> {
510 let resp = self.get("getStarred2")?;
511 Ok(resp.starred2.unwrap_or_default())
512 }
513
514 pub fn star_album(&self, album_id: &str) -> Result<(), SubsonicError> {
517 self.get_with_params("star", &[("albumId", album_id)])?;
518 Ok(())
519 }
520
521 pub fn unstar_album(&self, album_id: &str) -> Result<(), SubsonicError> {
522 self.get_with_params("unstar", &[("albumId", album_id)])?;
523 Ok(())
524 }
525
526 pub fn star_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
527 self.get_with_params("star", &[("artistId", artist_id)])?;
528 Ok(())
529 }
530
531 pub fn unstar_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
532 self.get_with_params("unstar", &[("artistId", artist_id)])?;
533 Ok(())
534 }
535
536 pub fn create_share(
539 &self,
540 ids: &[&str],
541 description: Option<&str>,
542 ) -> Result<SubsonicShare, SubsonicError> {
543 let url = format!("{}/rest/createShare", self.auth.base_url);
544 let mut params = self.auth_params()?;
545 if let Some(desc) = description {
546 params.insert("description".into(), desc.to_string());
547 }
548
549 let mut query: Vec<(String, String)> = params.into_iter().collect();
551 for id in ids {
552 query.push(("id".into(), (*id).to_string()));
553 }
554
555 let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
556
557 let inner = resp.subsonic_response;
558 if inner.status != "ok" {
559 if let Some(err) = inner.error {
560 return Err(SubsonicError::Api {
561 code: err.code,
562 message: err.message,
563 });
564 }
565 return Err(SubsonicError::BadResponse);
566 }
567
568 inner
569 .shares
570 .and_then(|s| s.share.into_iter().next())
571 .ok_or(SubsonicError::BadResponse)
572 }
573
574 pub fn get_similar_songs(
577 &self,
578 song_id: &str,
579 count: usize,
580 ) -> Result<Vec<SubsonicSong>, SubsonicError> {
581 let count_str = count.to_string();
582 let resp = self.get_with_params(
583 "getSimilarSongs2",
584 &[("id", song_id), ("count", &count_str)],
585 )?;
586 Ok(resp.similar_songs2.and_then(|s| s.song).unwrap_or_default())
587 }
588
589 pub fn get_top_songs(
591 &self,
592 artist_name: &str,
593 count: usize,
594 ) -> Result<Vec<SubsonicSong>, SubsonicError> {
595 let count_str = count.to_string();
596 let resp = self.get_with_params(
597 "getTopSongs",
598 &[("artist", artist_name), ("count", &count_str)],
599 )?;
600 Ok(resp.top_songs.and_then(|t| t.song).unwrap_or_default())
601 }
602
603 pub fn get_playlists(&self) -> Result<Vec<SubsonicPlaylist>, SubsonicError> {
607 let resp = self.get("getPlaylists")?;
608 Ok(resp.playlists.map(|p| p.playlist).unwrap_or_default())
609 }
610
611 pub fn get_playlist(&self, id: &str) -> Result<SubsonicPlaylistFull, SubsonicError> {
613 let resp = self.get_with_params("getPlaylist", &[("id", id)])?;
614 resp.playlist.ok_or(SubsonicError::BadResponse)
615 }
616
617 pub fn create_playlist(
626 &self,
627 playlist_id: Option<&str>,
628 name: &str,
629 song_ids: &[String],
630 ) -> Result<Option<SubsonicPlaylistFull>, SubsonicError> {
631 let url = format!("{}/rest/createPlaylist", self.auth.base_url);
632 let mut params = self.auth_params()?;
633 match playlist_id {
634 Some(id) => {
635 params.insert("playlistId".into(), id.to_string());
636 params.insert("name".into(), name.to_string());
639 }
640 None => {
641 params.insert("name".into(), name.to_string());
642 }
643 }
644
645 let mut query: Vec<(String, String)> = params.into_iter().collect();
647 for id in song_ids {
648 query.push(("songId".into(), id.clone()));
649 }
650
651 let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
652 let inner = resp.subsonic_response;
653 if inner.status != "ok" {
654 if let Some(err) = inner.error {
655 return Err(SubsonicError::Api {
656 code: err.code,
657 message: err.message,
658 });
659 }
660 return Err(SubsonicError::BadResponse);
661 }
662 Ok(inner.playlist)
666 }
667
668 pub fn update_playlist(
670 &self,
671 id: &str,
672 name: Option<&str>,
673 comment: Option<&str>,
674 public: Option<bool>,
675 ) -> Result<(), SubsonicError> {
676 let mut extra: Vec<(&str, String)> = vec![("playlistId", id.to_string())];
677 if let Some(name) = name {
678 extra.push(("name", name.to_string()));
679 }
680 if let Some(comment) = comment {
681 extra.push(("comment", comment.to_string()));
682 }
683 if let Some(public) = public {
684 extra.push(("public", public.to_string()));
685 }
686 let borrowed: Vec<(&str, &str)> = extra.iter().map(|(k, v)| (*k, v.as_str())).collect();
687 self.get_with_params("updatePlaylist", &borrowed)?;
688 Ok(())
689 }
690
691 pub fn delete_playlist(&self, id: &str) -> Result<(), SubsonicError> {
692 self.get_with_params("deletePlaylist", &[("id", id)])?;
693 Ok(())
694 }
695
696 pub fn koan_users(&self) -> Result<Vec<KoanUser>, SubsonicError> {
699 Ok(self
700 .get("koanUsers")?
701 .users
702 .map(|u| u.user)
703 .unwrap_or_default())
704 }
705
706 pub fn koan_create_user(
708 &self,
709 username: &str,
710 role: &str,
711 ) -> Result<KoanInvite, SubsonicError> {
712 self.get_with_params("koanCreateUser", &[("username", username), ("role", role)])?
713 .invite
714 .ok_or(SubsonicError::BadResponse)
715 }
716
717 pub fn koan_invite(&self, username: &str, reset: bool) -> Result<KoanInvite, SubsonicError> {
719 let reset = if reset { "true" } else { "false" };
720 self.get_with_params("koanInvite", &[("username", username), ("reset", reset)])?
721 .invite
722 .ok_or(SubsonicError::BadResponse)
723 }
724
725 pub fn koan_set_user_role(&self, username: &str, role: &str) -> Result<(), SubsonicError> {
726 self.get_with_params("koanSetUserRole", &[("username", username), ("role", role)])?;
727 Ok(())
728 }
729
730 pub fn koan_delete_user(&self, username: &str) -> Result<(), SubsonicError> {
731 self.get_with_params("koanDeleteUser", &[("username", username)])?;
732 Ok(())
733 }
734
735 pub fn koan_command(&self, to: &str, command: &str) -> Result<(), SubsonicError> {
738 self.get_with_params("koanCommand", &[("to", to), ("command", command)])?;
739 Ok(())
740 }
741
742 pub fn auth(&self) -> &SubsonicAuth {
743 &self.auth
744 }
745
746 pub fn base_url(&self) -> &str {
748 &self.auth.base_url
749 }
750}
751
752#[derive(Debug, Deserialize)]
755struct SubsonicResponseWrapper {
756 #[serde(rename = "subsonic-response")]
757 subsonic_response: SubsonicResponse,
758}
759
760#[derive(Debug, Deserialize)]
761#[serde(rename_all = "camelCase")]
762struct SubsonicResponse {
763 status: String,
764 #[serde(rename = "type")]
767 server_type: Option<String>,
768 server_version: Option<String>,
769 #[serde(default)]
770 open_subsonic: bool,
771 open_subsonic_extensions: Option<Vec<SubsonicExtension>>,
772 error: Option<SubsonicApiError>,
773 artists: Option<SubsonicArtists>,
774 album: Option<SubsonicAlbumFull>,
775 album_list2: Option<SubsonicAlbumList>,
776 search_result3: Option<SubsonicSearchResult>,
777 starred2: Option<SubsonicStarred>,
778 shares: Option<SubsonicShares>,
779 similar_songs2: Option<SubsonicSimilarSongs>,
780 top_songs: Option<SubsonicTopSongs>,
781 playlists: Option<SubsonicPlaylists>,
782 playlist: Option<SubsonicPlaylistFull>,
783 scan_status: Option<SubsonicScanStatus>,
784 users: Option<KoanUsers>,
785 invite: Option<KoanInvite>,
786}
787
788#[derive(Debug, Deserialize)]
789struct KoanUsers {
790 #[serde(default)]
791 user: Vec<KoanUser>,
792}
793
794#[derive(Debug, Clone, Deserialize)]
796pub struct KoanUser {
797 pub username: String,
798 pub role: String,
799}
800
801#[derive(Debug, Clone, Deserialize)]
804pub struct KoanInvite {
805 pub username: String,
806 pub password: String,
807}
808
809impl SubsonicResponse {
810 fn has_extension(&self, name: &str) -> bool {
811 self.open_subsonic_extensions
812 .iter()
813 .flatten()
814 .any(|ext| ext.name == name)
815 }
816}
817
818#[derive(Debug, Deserialize)]
819struct SubsonicExtension {
820 name: String,
821 #[serde(default)]
822 versions: Vec<i64>,
823}
824
825#[derive(Debug, Deserialize)]
826struct SubsonicScanStatus {
827 count: Option<u64>,
828}
829
830#[derive(Debug, Deserialize)]
831struct SubsonicApiError {
832 code: i32,
833 message: String,
834}
835
836#[derive(Debug, Deserialize)]
837struct SubsonicArtists {
838 index: Vec<SubsonicArtistIndex>,
839}
840
841#[derive(Debug, Deserialize)]
842struct SubsonicArtistIndex {
843 artist: Vec<SubsonicArtist>,
844}
845
846#[derive(Debug, Clone, Deserialize)]
847#[serde(rename_all = "camelCase")]
848pub struct SubsonicArtist {
849 pub id: String,
850 pub name: String,
851 pub album_count: Option<i32>,
852 #[serde(default, deserialize_with = "non_empty")]
855 pub music_brainz_id: Option<String>,
856 #[serde(default, deserialize_with = "non_empty")]
857 pub sort_name: Option<String>,
858}
859
860#[derive(Debug, Clone, Deserialize)]
861#[serde(rename_all = "camelCase")]
862pub struct SubsonicAlbum {
863 pub id: String,
864 pub name: String,
865 pub artist: Option<String>,
866 pub artist_id: Option<String>,
867 pub song_count: Option<i32>,
868 #[serde(default)]
870 pub duration: Option<i64>,
871 pub year: Option<i32>,
872 #[serde(default, deserialize_with = "non_empty")]
873 pub genre: Option<String>,
874 pub created: Option<String>,
875 #[serde(default, deserialize_with = "non_empty")]
878 pub music_brainz_id: Option<String>,
879 #[serde(default, deserialize_with = "non_empty")]
880 pub sort_name: Option<String>,
881 #[serde(default)]
882 pub record_labels: Vec<SubsonicName>,
883}
884
885#[derive(Debug, Clone, Deserialize)]
888pub struct SubsonicName {
889 pub name: String,
890}
891
892#[derive(Debug, Clone, Deserialize)]
893#[serde(rename_all = "camelCase")]
894pub struct SubsonicAlbumFull {
895 pub id: String,
896 pub name: String,
897 pub artist: Option<String>,
898 pub artist_id: Option<String>,
899 pub year: Option<i32>,
900 #[serde(default, deserialize_with = "non_empty")]
901 pub genre: Option<String>,
902 pub song_count: Option<i32>,
903 pub created: Option<String>,
904 #[serde(default, deserialize_with = "non_empty")]
905 pub music_brainz_id: Option<String>,
906 #[serde(default, deserialize_with = "non_empty")]
907 pub sort_name: Option<String>,
908 #[serde(default)]
909 pub record_labels: Vec<SubsonicName>,
910 #[serde(default)]
911 pub song: Vec<SubsonicSong>,
912}
913
914#[derive(Debug, Clone, Deserialize)]
915#[serde(rename_all = "camelCase")]
916pub struct SubsonicSong {
917 pub id: String,
918 pub title: String,
919 pub album: Option<String>,
920 pub artist: Option<String>,
921 pub track: Option<i32>,
922 pub disc_number: Option<i32>,
923 pub year: Option<i32>,
924 #[serde(default, deserialize_with = "non_empty")]
925 pub genre: Option<String>,
926 pub duration: Option<i64>,
927 pub bit_rate: Option<i32>,
928 pub suffix: Option<String>,
929 pub content_type: Option<String>,
930 pub album_id: Option<String>,
931 pub artist_id: Option<String>,
932 pub sampling_rate: Option<i32>,
935 pub bit_depth: Option<i32>,
936 pub channel_count: Option<i32>,
937 #[serde(default, deserialize_with = "non_empty")]
938 pub music_brainz_id: Option<String>,
939}
940
941#[derive(Debug, Deserialize)]
942struct SubsonicAlbumList {
943 #[serde(default)]
944 album: Vec<SubsonicAlbum>,
945}
946
947#[derive(Debug, Default, Deserialize)]
948pub struct SubsonicSearchResult {
949 #[serde(default)]
950 pub artist: Vec<SubsonicArtist>,
951 #[serde(default)]
952 pub album: Vec<SubsonicAlbum>,
953 #[serde(default)]
954 pub song: Vec<SubsonicSong>,
955}
956
957#[derive(Debug, Default, Deserialize)]
958pub struct SubsonicStarred {
959 #[serde(default)]
960 pub song: Vec<SubsonicSong>,
961 #[serde(default)]
962 pub album: Vec<SubsonicAlbum>,
963 #[serde(default)]
964 pub artist: Vec<SubsonicArtist>,
965}
966
967#[derive(Debug, Deserialize)]
968pub struct SubsonicSimilarSongs {
969 pub song: Option<Vec<SubsonicSong>>,
970}
971
972#[derive(Debug, Deserialize)]
973pub struct SubsonicTopSongs {
974 pub song: Option<Vec<SubsonicSong>>,
975}
976
977#[derive(Debug, Default, Deserialize)]
978struct SubsonicPlaylists {
979 #[serde(default)]
980 playlist: Vec<SubsonicPlaylist>,
981}
982
983#[derive(Debug, Clone, Deserialize)]
985#[serde(rename_all = "camelCase")]
986pub struct SubsonicPlaylist {
987 pub id: String,
988 pub name: String,
989 pub comment: Option<String>,
990 pub owner: Option<String>,
991 #[serde(default)]
992 pub public: bool,
993 pub song_count: Option<i64>,
994 pub duration: Option<i64>,
995 pub created: Option<String>,
996 pub changed: Option<String>,
997}
998
999#[derive(Debug, Clone, Deserialize)]
1000#[serde(rename_all = "camelCase")]
1001pub struct SubsonicPlaylistFull {
1002 #[serde(flatten)]
1003 pub playlist: SubsonicPlaylist,
1004 #[serde(default)]
1005 pub entry: Vec<SubsonicSong>,
1006}
1007
1008#[derive(Debug, Deserialize)]
1009struct SubsonicShares {
1010 #[serde(default)]
1011 share: Vec<SubsonicShare>,
1012}
1013
1014#[derive(Debug, Clone, Deserialize)]
1015#[serde(rename_all = "camelCase")]
1016pub struct SubsonicShare {
1017 pub id: String,
1018 pub url: Option<String>,
1019 pub description: Option<String>,
1020 pub username: Option<String>,
1021 pub created: Option<String>,
1022 pub expires: Option<String>,
1023 pub visit_count: Option<i64>,
1024}
1025
1026fn non_empty<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
1033 Ok(Option::<String>::deserialize(d)?.filter(|s| !s.is_empty()))
1034}
1035
1036fn random_salt() -> Result<String, getrandom::Error> {
1043 let mut buf = [0u8; 12];
1044 getrandom::fill(&mut buf)?;
1045 Ok(buf.iter().map(|b| format!("{:02x}", b)).collect())
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050 use super::*;
1051
1052 fn response(json: &str) -> SubsonicResponse {
1053 serde_json::from_str::<SubsonicResponseWrapper>(json)
1054 .unwrap()
1055 .subsonic_response
1056 }
1057
1058 #[test]
1059 fn playback_report_is_read_from_the_advertised_extensions() {
1060 let navidrome = response(
1061 r#"{"subsonic-response": {
1062 "status": "ok", "version": "1.16.1", "type": "navidrome",
1063 "openSubsonic": true,
1064 "openSubsonicExtensions": [
1065 {"name": "transcodeOffset", "versions": [1]},
1066 {"name": "playbackReport", "versions": [1]}
1067 ]
1068 }}"#,
1069 );
1070 assert!(navidrome.has_extension(PLAYBACK_REPORT_EXTENSION));
1071
1072 let without = response(
1073 r#"{"subsonic-response": {
1074 "status": "ok", "version": "1.16.1",
1075 "openSubsonicExtensions": [{"name": "songLyrics", "versions": [1, 2]}]
1076 }}"#,
1077 );
1078 assert!(!without.has_extension(PLAYBACK_REPORT_EXTENSION));
1079
1080 let plain = response(r#"{"subsonic-response": {"status": "ok", "version": "1.16.1"}}"#);
1081 assert!(!plain.has_extension(PLAYBACK_REPORT_EXTENSION));
1082 }
1083
1084 #[test]
1087 fn test_deserialize_subsonic_song() {
1088 let json = r#"{
1089 "id": "42",
1090 "title": "Space Oddity",
1091 "album": "Space Oddity",
1092 "artist": "David Bowie",
1093 "track": 1,
1094 "discNumber": 1,
1095 "year": 1969,
1096 "genre": "Rock",
1097 "duration": 314,
1098 "bitRate": 320,
1099 "suffix": "mp3",
1100 "contentType": "audio/mpeg",
1101 "albumId": "7",
1102 "artistId": "3"
1103 }"#;
1104
1105 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1106
1107 assert_eq!(song.id, "42");
1108 assert_eq!(song.title, "Space Oddity");
1109 assert_eq!(song.album.as_deref(), Some("Space Oddity"));
1110 assert_eq!(song.artist.as_deref(), Some("David Bowie"));
1111 assert_eq!(song.track, Some(1));
1112 assert_eq!(song.disc_number, Some(1));
1113 assert_eq!(song.year, Some(1969));
1114 assert_eq!(song.genre.as_deref(), Some("Rock"));
1115 assert_eq!(song.duration, Some(314));
1116 assert_eq!(song.bit_rate, Some(320));
1117 assert_eq!(song.suffix.as_deref(), Some("mp3"));
1118 assert_eq!(song.content_type.as_deref(), Some("audio/mpeg"));
1119 assert_eq!(song.album_id.as_deref(), Some("7"));
1120 assert_eq!(song.artist_id.as_deref(), Some("3"));
1121 }
1122
1123 #[test]
1127 fn opensubsonic_quality_fields_are_read() {
1128 let json = r#"{
1129 "id": "000XtGC7jsWEbOjDsZi4Xw",
1130 "title": "Anguish",
1131 "suffix": "flac",
1132 "bitRate": 913,
1133 "samplingRate": 44100,
1134 "bitDepth": 16,
1135 "channelCount": 2
1136 }"#;
1137
1138 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1139
1140 assert_eq!(song.sampling_rate, Some(44100));
1141 assert_eq!(song.bit_depth, Some(16));
1142 assert_eq!(song.channel_count, Some(2));
1143 }
1144
1145 #[test]
1148 fn a_plain_subsonic_song_has_no_quality_figures() {
1149 let json = r#"{"id": "1", "title": "Track", "bitRate": 320}"#;
1150 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1151
1152 assert_eq!(song.sampling_rate, None);
1153 assert_eq!(song.bit_depth, None);
1154 assert_eq!(song.channel_count, None);
1155 }
1156
1157 #[test]
1158 fn test_deserialize_subsonic_song_optional_fields_absent() {
1159 let json = r#"{"id": "99", "title": "Minimal Track"}"#;
1161
1162 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1163
1164 assert_eq!(song.id, "99");
1165 assert_eq!(song.title, "Minimal Track");
1166 assert!(song.album.is_none());
1167 assert!(song.artist.is_none());
1168 assert!(song.track.is_none());
1169 assert!(song.disc_number.is_none());
1170 assert!(song.year.is_none());
1171 assert!(song.duration.is_none());
1172 assert!(song.bit_rate.is_none());
1173 }
1174
1175 #[test]
1178 fn test_deserialize_album_list() {
1179 let json = r#"{
1180 "subsonic-response": {
1181 "status": "ok",
1182 "version": "1.16.1",
1183 "albumList2": {
1184 "album": [
1185 {
1186 "id": "1",
1187 "name": "Abbey Road",
1188 "artist": "The Beatles",
1189 "artistId": "10",
1190 "songCount": 17,
1191 "year": 1969,
1192 "genre": "Rock",
1193 "created": "2020-01-01T00:00:00"
1194 },
1195 {
1196 "id": "2",
1197 "name": "Led Zeppelin IV",
1198 "artist": "Led Zeppelin",
1199 "artistId": "11",
1200 "songCount": 8,
1201 "year": 1971,
1202 "genre": "Hard Rock",
1203 "created": "2020-01-02T00:00:00"
1204 }
1205 ]
1206 }
1207 }
1208 }"#;
1209
1210 let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
1211 let album_list = wrapper
1212 .subsonic_response
1213 .album_list2
1214 .expect("album_list2 should be present");
1215
1216 assert_eq!(album_list.album.len(), 2);
1217
1218 let first = &album_list.album[0];
1219 assert_eq!(first.id, "1");
1220 assert_eq!(first.name, "Abbey Road");
1221 assert_eq!(first.artist.as_deref(), Some("The Beatles"));
1222 assert_eq!(first.artist_id.as_deref(), Some("10"));
1223 assert_eq!(first.song_count, Some(17));
1224 assert_eq!(first.year, Some(1969));
1225
1226 let second = &album_list.album[1];
1227 assert_eq!(second.id, "2");
1228 assert_eq!(second.name, "Led Zeppelin IV");
1229 assert_eq!(second.song_count, Some(8));
1230 }
1231
1232 #[test]
1235 fn test_auth_params_format() {
1236 let client = SubsonicClient::new("http://localhost:4533", "alice", "secret");
1237 let params = client.auth_params().unwrap();
1238
1239 assert!(params.contains_key("u"), "missing 'u' param");
1241 assert!(params.contains_key("t"), "missing 't' param");
1242 assert!(params.contains_key("s"), "missing 's' param");
1243 assert!(params.contains_key("v"), "missing 'v' param");
1244 assert!(params.contains_key("c"), "missing 'c' param");
1245 assert!(params.contains_key("f"), "missing 'f' param");
1246 assert_eq!(params.len(), 6);
1247
1248 assert_eq!(params["u"], "alice");
1249 assert_eq!(params["v"], "1.16.1");
1250 assert_eq!(params["c"], "koan");
1251 assert_eq!(params["f"], "json");
1252 }
1253
1254 #[test]
1255 fn test_auth_params_over_https_send_the_hex_password() {
1256 let client = SubsonicClient::new("https://koan.example", "alice", "hi");
1257 let params = client.auth_params().unwrap();
1258 assert_eq!(params["p"], "enc:6869");
1259 assert!(!params.contains_key("t") && !params.contains_key("s"));
1260 }
1261
1262 #[test]
1263 fn test_auth_params_token_is_md5_of_password_plus_salt() {
1264 let client = SubsonicClient::new("http://localhost:4533", "bob", "letmein");
1265 let params = client.auth_params().unwrap();
1266
1267 let salt = ¶ms["s"];
1268 let token = ¶ms["t"];
1269
1270 let expected = format!("{:x}", md5::compute(format!("letmein{}", salt)));
1272 assert_eq!(token, &expected);
1273 }
1274
1275 #[test]
1276 fn test_auth_params_salt_is_different_each_call() {
1277 let client = SubsonicClient::new("http://localhost:4533", "user", "pass");
1278 let params1 = client.auth_params().unwrap();
1279 let params2 = client.auth_params().unwrap();
1280
1281 assert_ne!(params1["s"], params2["s"], "salt should be random per call");
1284 }
1285
1286 #[test]
1289 fn test_stream_url_has_auth() {
1290 let client = SubsonicClient::new("http://myserver:4533", "user", "pass");
1291 let url = client.stream_url("track-123").unwrap();
1292
1293 assert!(url.contains("track-123"), "url must include the track id");
1294 assert!(url.contains("u=user"), "url must include username param");
1295 assert!(url.contains("v=1.16.1"), "url must include api version");
1296 assert!(url.contains("c=koan"), "url must include client name");
1297 assert!(url.contains("f=json"), "url must include format param");
1298 assert!(url.contains("/rest/stream"), "url must target /rest/stream");
1299 assert!(
1300 url.starts_with("http://myserver:4533"),
1301 "url must use the configured base_url"
1302 );
1303 }
1304
1305 #[test]
1306 fn test_stream_url_base_url_trailing_slash_normalised() {
1307 let client_with_slash = SubsonicClient::new("http://myserver:4533/", "u", "p");
1309 let client_no_slash = SubsonicClient::new("http://myserver:4533", "u", "p");
1310
1311 let url_with = client_with_slash.stream_url("1").unwrap();
1312 let url_without = client_no_slash.stream_url("1").unwrap();
1313
1314 assert!(
1316 url_with.contains("/rest/stream"),
1317 "should not have double slash"
1318 );
1319 assert!(!url_with.contains("//rest"), "should not have double slash");
1320 assert_eq!(
1322 url_with.split('?').next(),
1323 url_without.split('?').next(),
1324 "path segment should be identical regardless of trailing slash"
1325 );
1326 }
1327
1328 #[test]
1331 fn test_deserialize_album_full_with_songs() {
1332 let json = r#"{
1333 "id": "5",
1334 "name": "Kind of Blue",
1335 "artist": "Miles Davis",
1336 "artistId": "20",
1337 "year": 1959,
1338 "genre": "Jazz",
1339 "songCount": 5,
1340 "created": "2021-06-01T00:00:00",
1341 "song": [
1342 {"id": "101", "title": "So What"},
1343 {"id": "102", "title": "Freddie Freeloader"},
1344 {"id": "103", "title": "Blue in Green"}
1345 ]
1346 }"#;
1347
1348 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1349
1350 assert_eq!(album.id, "5");
1351 assert_eq!(album.name, "Kind of Blue");
1352 assert_eq!(album.artist.as_deref(), Some("Miles Davis"));
1353 assert_eq!(album.year, Some(1959));
1354 assert_eq!(album.song.len(), 3);
1355 assert_eq!(album.song[0].title, "So What");
1356 assert_eq!(album.song[2].id, "103");
1357 }
1358
1359 #[test]
1360 fn test_deserialize_album_full_empty_song_list() {
1361 let json = r#"{"id": "9", "name": "No Tracks Yet"}"#;
1363
1364 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1365
1366 assert_eq!(album.id, "9");
1367 assert!(album.song.is_empty(), "song list should default to empty");
1368 }
1369
1370 #[test]
1373 fn test_deserialize_search_result_mixed() {
1374 let json = r#"{
1375 "artist": [{"id": "1", "name": "Artist One"}],
1376 "album": [{"id": "2", "name": "Album One"}],
1377 "song": [{"id": "3", "title": "Song One"}]
1378 }"#;
1379
1380 let result: SubsonicSearchResult = serde_json::from_str(json).unwrap();
1381
1382 assert_eq!(result.artist.len(), 1);
1383 assert_eq!(result.artist[0].name, "Artist One");
1384 assert_eq!(result.album.len(), 1);
1385 assert_eq!(result.album[0].name, "Album One");
1386 assert_eq!(result.song.len(), 1);
1387 assert_eq!(result.song[0].title, "Song One");
1388 }
1389
1390 #[test]
1391 fn empty_opensubsonic_ids_are_absent() {
1392 let json = r#"{"id": "1", "title": "T", "musicBrainzId": ""}"#;
1393 let song: SubsonicSong = serde_json::from_str(json).unwrap();
1394 assert_eq!(song.music_brainz_id, None);
1395
1396 let json = r#"{"id": "2", "name": "A", "musicBrainzId": "", "sortName": ""}"#;
1397 let album: SubsonicAlbum = serde_json::from_str(json).unwrap();
1398 assert_eq!((album.music_brainz_id, album.sort_name), (None, None));
1399
1400 let json = r#"{"id": "3", "name": "A", "musicBrainzId": "mb-1"}"#;
1401 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1402 assert_eq!(album.music_brainz_id.as_deref(), Some("mb-1"));
1403 assert_eq!(album.sort_name, None);
1404 }
1405
1406 #[test]
1407 fn test_deserialize_scan_status_count() {
1408 let json = r#"{"subsonic-response":{"status":"ok","scanStatus":{"scanning":false,"count":49700}}}"#;
1409 let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
1410 let status = wrapper.subsonic_response.scan_status.unwrap();
1411 assert_eq!(status.count, Some(49_700));
1412 }
1413
1414 #[test]
1415 fn test_deserialize_search_result_defaults_to_empty() {
1416 let result: SubsonicSearchResult = serde_json::from_str("{}").unwrap();
1418
1419 assert!(result.artist.is_empty());
1420 assert!(result.album.is_empty());
1421 assert!(result.song.is_empty());
1422 }
1423}