1use std::collections::HashMap;
2use std::path::Path;
3
4use serde::Deserialize;
5use thiserror::Error;
6
7use super::download::{self, DownloadError};
8
9const API_VERSION: &str = "1.16.1";
10const CLIENT_NAME: &str = "koan";
11
12#[derive(Debug, Error)]
13pub enum SubsonicError {
14 #[error("http error: {0}")]
15 Http(#[from] reqwest::Error),
16 #[error("api error: {code} — {message}")]
17 Api { code: i32, message: String },
18 #[error("unexpected response format")]
19 BadResponse,
20 #[error("io error: {0}")]
21 Io(#[from] std::io::Error),
22 #[error("download error: {0}")]
23 Download(#[from] DownloadError),
24 #[error("entropy source unavailable: {0}")]
25 Entropy(#[from] getrandom::Error),
26}
27
28#[derive(Debug, Clone)]
35pub struct SubsonicAuth {
36 pub base_url: String,
37 pub username: String,
38 pub password: String,
39}
40
41impl SubsonicAuth {
42 pub fn new(base_url: &str, username: &str, password: &str) -> Self {
43 Self {
44 base_url: base_url.trim_end_matches('/').to_string(),
45 username: username.to_string(),
46 password: password.to_string(),
47 }
48 }
49
50 fn params(&self) -> Result<HashMap<String, String>, SubsonicError> {
52 let salt = random_salt()?;
53
54 let token = format!("{:x}", md5::compute(format!("{}{}", self.password, salt)));
55
56 let mut params = HashMap::new();
57 params.insert("u".into(), self.username.clone());
58 params.insert("t".into(), token);
59 params.insert("s".into(), salt);
60 params.insert("v".into(), API_VERSION.into());
61 params.insert("c".into(), CLIENT_NAME.into());
62 params.insert("f".into(), "json".into());
63 Ok(params)
64 }
65
66 pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
68 let query: String = self
69 .params()?
70 .iter()
71 .map(|(k, v)| format!("{}={}", k, v))
72 .collect::<Vec<_>>()
73 .join("&");
74 Ok(format!(
75 "{}/rest/stream?id={}&{}",
76 self.base_url, track_id, query
77 ))
78 }
79}
80
81pub struct SubsonicClient {
88 auth: SubsonicAuth,
89 http: reqwest::blocking::Client,
90 downloader: reqwest::blocking::Client,
91}
92
93impl SubsonicClient {
94 pub fn new(base_url: &str, username: &str, password: &str) -> Self {
95 Self::from_auth(SubsonicAuth::new(base_url, username, password))
96 }
97
98 pub fn from_auth(auth: SubsonicAuth) -> Self {
99 Self {
100 auth,
101 http: download::api_client().unwrap_or_else(|e| {
102 log::warn!("falling back to default HTTP client: {}", e);
103 reqwest::blocking::Client::new()
104 }),
105 downloader: download::download_client().unwrap_or_else(|e| {
106 log::warn!("falling back to default download client: {}", e);
107 reqwest::blocking::Client::new()
108 }),
109 }
110 }
111
112 fn auth_params(&self) -> Result<HashMap<String, String>, SubsonicError> {
113 self.auth.params()
114 }
115
116 fn get(&self, endpoint: &str) -> Result<SubsonicResponse, SubsonicError> {
118 self.get_with_params(endpoint, &[])
119 }
120
121 fn get_with_params(
122 &self,
123 endpoint: &str,
124 extra: &[(&str, &str)],
125 ) -> Result<SubsonicResponse, SubsonicError> {
126 let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
127 let mut params = self.auth_params()?;
128 for (k, v) in extra {
129 params.insert((*k).to_string(), (*v).to_string());
130 }
131
132 let resp: SubsonicResponseWrapper = self.http.get(&url).query(¶ms).send()?.json()?;
133
134 let inner = resp.subsonic_response;
135 if inner.status != "ok" {
136 if let Some(err) = inner.error {
137 return Err(SubsonicError::Api {
138 code: err.code,
139 message: err.message,
140 });
141 }
142 return Err(SubsonicError::BadResponse);
143 }
144
145 Ok(inner)
146 }
147
148 pub fn ping(&self) -> Result<(), SubsonicError> {
150 self.get("ping")?;
151 Ok(())
152 }
153
154 pub fn get_artists(&self) -> Result<Vec<SubsonicArtist>, SubsonicError> {
156 let resp = self.get("getArtists")?;
157 let artists_data = resp.artists.ok_or(SubsonicError::BadResponse)?;
158 let mut all = Vec::new();
159 for index in artists_data.index {
160 all.extend(index.artist);
161 }
162 Ok(all)
163 }
164
165 pub fn get_album(&self, id: &str) -> Result<SubsonicAlbumFull, SubsonicError> {
167 let resp = self.get_with_params("getAlbum", &[("id", id)])?;
168 resp.album.ok_or(SubsonicError::BadResponse)
169 }
170
171 pub fn get_album_list(
173 &self,
174 list_type: &str,
175 size: u32,
176 offset: u32,
177 ) -> Result<Vec<SubsonicAlbum>, SubsonicError> {
178 let size_str = size.to_string();
179 let offset_str = offset.to_string();
180 let resp = self.get_with_params(
181 "getAlbumList2",
182 &[
183 ("type", list_type),
184 ("size", &size_str),
185 ("offset", &offset_str),
186 ],
187 )?;
188 Ok(resp.album_list2.map(|al| al.album).unwrap_or_default())
189 }
190
191 pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
193 self.auth.stream_url(track_id)
194 }
195
196 pub fn stream_url_template(&self, track_id: &str) -> String {
198 format!("{}/rest/stream?id={}", self.auth.base_url, track_id)
199 }
200
201 pub fn download(&self, track_id: &str, dest: &Path) -> Result<(), SubsonicError> {
203 self.download_with_progress(track_id, dest, |_, _| {})
204 }
205
206 pub fn download_with_progress(
212 &self,
213 track_id: &str,
214 dest: &Path,
215 on_progress: impl Fn(u64, u64),
216 ) -> Result<(), SubsonicError> {
217 self.fetch_to_file("download", track_id, dest, on_progress)
218 }
219
220 pub fn stream_to_file(
226 &self,
227 track_id: &str,
228 dest: &Path,
229 on_progress: impl Fn(u64, u64),
230 ) -> Result<(), SubsonicError> {
231 self.fetch_to_file("stream", track_id, dest, on_progress)
232 }
233
234 fn fetch_to_file(
235 &self,
236 endpoint: &str,
237 track_id: &str,
238 dest: &Path,
239 on_progress: impl Fn(u64, u64),
240 ) -> Result<(), SubsonicError> {
241 let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
242 download::download_with_retries(
243 dest,
244 download::DEFAULT_ATTEMPTS,
245 || {
246 let mut params = self
248 .auth_params()
249 .map_err(|e| download::DownloadError::Request(e.to_string()))?;
250 params.insert("id".into(), track_id.to_string());
251 Ok(self.downloader.get(&url).query(¶ms))
252 },
253 on_progress,
254 )?;
255 Ok(())
256 }
257
258 pub fn search(&self, query: &str) -> Result<SubsonicSearchResult, SubsonicError> {
260 let resp = self.get_with_params("search3", &[("query", query)])?;
261 Ok(resp.search_result3.unwrap_or_default())
262 }
263
264 pub fn scrobble(&self, track_id: &str) -> Result<(), SubsonicError> {
266 self.get_with_params("scrobble", &[("id", track_id)])?;
267 Ok(())
268 }
269
270 pub fn star(&self, track_id: &str) -> Result<(), SubsonicError> {
272 self.get_with_params("star", &[("id", track_id)])?;
273 Ok(())
274 }
275
276 pub fn unstar(&self, track_id: &str) -> Result<(), SubsonicError> {
278 self.get_with_params("unstar", &[("id", track_id)])?;
279 Ok(())
280 }
281
282 pub fn get_starred(&self) -> Result<Vec<SubsonicSong>, SubsonicError> {
284 let resp = self.get("getStarred2")?;
285 Ok(resp.starred2.map(|s| s.song).unwrap_or_default())
286 }
287
288 pub fn create_share(
291 &self,
292 ids: &[&str],
293 description: Option<&str>,
294 ) -> Result<SubsonicShare, SubsonicError> {
295 let url = format!("{}/rest/createShare", self.auth.base_url);
296 let mut params = self.auth_params()?;
297 if let Some(desc) = description {
298 params.insert("description".into(), desc.to_string());
299 }
300
301 let mut query: Vec<(String, String)> = params.into_iter().collect();
303 for id in ids {
304 query.push(("id".into(), (*id).to_string()));
305 }
306
307 let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
308
309 let inner = resp.subsonic_response;
310 if inner.status != "ok" {
311 if let Some(err) = inner.error {
312 return Err(SubsonicError::Api {
313 code: err.code,
314 message: err.message,
315 });
316 }
317 return Err(SubsonicError::BadResponse);
318 }
319
320 inner
321 .shares
322 .and_then(|s| s.share.into_iter().next())
323 .ok_or(SubsonicError::BadResponse)
324 }
325
326 pub fn get_similar_songs(
329 &self,
330 song_id: &str,
331 count: usize,
332 ) -> Result<Vec<SubsonicSong>, SubsonicError> {
333 let count_str = count.to_string();
334 let resp = self.get_with_params(
335 "getSimilarSongs2",
336 &[("id", song_id), ("count", &count_str)],
337 )?;
338 Ok(resp.similar_songs2.and_then(|s| s.song).unwrap_or_default())
339 }
340
341 pub fn get_top_songs(
343 &self,
344 artist_name: &str,
345 count: usize,
346 ) -> Result<Vec<SubsonicSong>, SubsonicError> {
347 let count_str = count.to_string();
348 let resp = self.get_with_params(
349 "getTopSongs",
350 &[("artist", artist_name), ("count", &count_str)],
351 )?;
352 Ok(resp.top_songs.and_then(|t| t.song).unwrap_or_default())
353 }
354
355 pub fn base_url(&self) -> &str {
357 &self.auth.base_url
358 }
359}
360
361#[derive(Debug, Deserialize)]
364struct SubsonicResponseWrapper {
365 #[serde(rename = "subsonic-response")]
366 subsonic_response: SubsonicResponse,
367}
368
369#[derive(Debug, Deserialize)]
370#[serde(rename_all = "camelCase")]
371struct SubsonicResponse {
372 status: String,
373 error: Option<SubsonicApiError>,
374 artists: Option<SubsonicArtists>,
375 album: Option<SubsonicAlbumFull>,
376 album_list2: Option<SubsonicAlbumList>,
377 search_result3: Option<SubsonicSearchResult>,
378 starred2: Option<SubsonicStarred>,
379 shares: Option<SubsonicShares>,
380 similar_songs2: Option<SubsonicSimilarSongs>,
381 top_songs: Option<SubsonicTopSongs>,
382}
383
384#[derive(Debug, Deserialize)]
385struct SubsonicApiError {
386 code: i32,
387 message: String,
388}
389
390#[derive(Debug, Deserialize)]
391struct SubsonicArtists {
392 index: Vec<SubsonicArtistIndex>,
393}
394
395#[derive(Debug, Deserialize)]
396struct SubsonicArtistIndex {
397 artist: Vec<SubsonicArtist>,
398}
399
400#[derive(Debug, Clone, Deserialize)]
401#[serde(rename_all = "camelCase")]
402pub struct SubsonicArtist {
403 pub id: String,
404 pub name: String,
405 pub album_count: Option<i32>,
406}
407
408#[derive(Debug, Clone, Deserialize)]
409#[serde(rename_all = "camelCase")]
410pub struct SubsonicAlbum {
411 pub id: String,
412 pub name: String,
413 pub artist: Option<String>,
414 pub artist_id: Option<String>,
415 pub song_count: Option<i32>,
416 pub year: Option<i32>,
417 pub genre: Option<String>,
418 pub created: Option<String>,
419}
420
421#[derive(Debug, Clone, Deserialize)]
422#[serde(rename_all = "camelCase")]
423pub struct SubsonicAlbumFull {
424 pub id: String,
425 pub name: String,
426 pub artist: Option<String>,
427 pub artist_id: Option<String>,
428 pub year: Option<i32>,
429 pub genre: Option<String>,
430 pub song_count: Option<i32>,
431 pub created: Option<String>,
432 #[serde(default)]
433 pub song: Vec<SubsonicSong>,
434}
435
436#[derive(Debug, Clone, Deserialize)]
437#[serde(rename_all = "camelCase")]
438pub struct SubsonicSong {
439 pub id: String,
440 pub title: String,
441 pub album: Option<String>,
442 pub artist: Option<String>,
443 pub track: Option<i32>,
444 pub disc_number: Option<i32>,
445 pub year: Option<i32>,
446 pub genre: Option<String>,
447 pub duration: Option<i64>,
448 pub bit_rate: Option<i32>,
449 pub suffix: Option<String>,
450 pub content_type: Option<String>,
451 pub album_id: Option<String>,
452 pub artist_id: Option<String>,
453}
454
455#[derive(Debug, Deserialize)]
456struct SubsonicAlbumList {
457 #[serde(default)]
458 album: Vec<SubsonicAlbum>,
459}
460
461#[derive(Debug, Default, Deserialize)]
462pub struct SubsonicSearchResult {
463 #[serde(default)]
464 pub artist: Vec<SubsonicArtist>,
465 #[serde(default)]
466 pub album: Vec<SubsonicAlbum>,
467 #[serde(default)]
468 pub song: Vec<SubsonicSong>,
469}
470
471#[derive(Debug, Default, Deserialize)]
472pub struct SubsonicStarred {
473 #[serde(default)]
474 pub song: Vec<SubsonicSong>,
475}
476
477#[derive(Debug, Deserialize)]
478pub struct SubsonicSimilarSongs {
479 pub song: Option<Vec<SubsonicSong>>,
480}
481
482#[derive(Debug, Deserialize)]
483pub struct SubsonicTopSongs {
484 pub song: Option<Vec<SubsonicSong>>,
485}
486
487#[derive(Debug, Deserialize)]
488struct SubsonicShares {
489 #[serde(default)]
490 share: Vec<SubsonicShare>,
491}
492
493#[derive(Debug, Clone, Deserialize)]
494#[serde(rename_all = "camelCase")]
495pub struct SubsonicShare {
496 pub id: String,
497 pub url: Option<String>,
498 pub description: Option<String>,
499 pub username: Option<String>,
500 pub created: Option<String>,
501 pub expires: Option<String>,
502 pub visit_count: Option<i64>,
503}
504
505fn random_salt() -> Result<String, getrandom::Error> {
512 let mut buf = [0u8; 12];
513 getrandom::fill(&mut buf)?;
514 Ok(buf.iter().map(|b| format!("{:02x}", b)).collect())
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 #[test]
524 fn test_deserialize_subsonic_song() {
525 let json = r#"{
526 "id": "42",
527 "title": "Space Oddity",
528 "album": "Space Oddity",
529 "artist": "David Bowie",
530 "track": 1,
531 "discNumber": 1,
532 "year": 1969,
533 "genre": "Rock",
534 "duration": 314,
535 "bitRate": 320,
536 "suffix": "mp3",
537 "contentType": "audio/mpeg",
538 "albumId": "7",
539 "artistId": "3"
540 }"#;
541
542 let song: SubsonicSong = serde_json::from_str(json).unwrap();
543
544 assert_eq!(song.id, "42");
545 assert_eq!(song.title, "Space Oddity");
546 assert_eq!(song.album.as_deref(), Some("Space Oddity"));
547 assert_eq!(song.artist.as_deref(), Some("David Bowie"));
548 assert_eq!(song.track, Some(1));
549 assert_eq!(song.disc_number, Some(1));
550 assert_eq!(song.year, Some(1969));
551 assert_eq!(song.genre.as_deref(), Some("Rock"));
552 assert_eq!(song.duration, Some(314));
553 assert_eq!(song.bit_rate, Some(320));
554 assert_eq!(song.suffix.as_deref(), Some("mp3"));
555 assert_eq!(song.content_type.as_deref(), Some("audio/mpeg"));
556 assert_eq!(song.album_id.as_deref(), Some("7"));
557 assert_eq!(song.artist_id.as_deref(), Some("3"));
558 }
559
560 #[test]
561 fn test_deserialize_subsonic_song_optional_fields_absent() {
562 let json = r#"{"id": "99", "title": "Minimal Track"}"#;
564
565 let song: SubsonicSong = serde_json::from_str(json).unwrap();
566
567 assert_eq!(song.id, "99");
568 assert_eq!(song.title, "Minimal Track");
569 assert!(song.album.is_none());
570 assert!(song.artist.is_none());
571 assert!(song.track.is_none());
572 assert!(song.disc_number.is_none());
573 assert!(song.year.is_none());
574 assert!(song.duration.is_none());
575 assert!(song.bit_rate.is_none());
576 }
577
578 #[test]
581 fn test_deserialize_album_list() {
582 let json = r#"{
583 "subsonic-response": {
584 "status": "ok",
585 "version": "1.16.1",
586 "albumList2": {
587 "album": [
588 {
589 "id": "1",
590 "name": "Abbey Road",
591 "artist": "The Beatles",
592 "artistId": "10",
593 "songCount": 17,
594 "year": 1969,
595 "genre": "Rock",
596 "created": "2020-01-01T00:00:00"
597 },
598 {
599 "id": "2",
600 "name": "Led Zeppelin IV",
601 "artist": "Led Zeppelin",
602 "artistId": "11",
603 "songCount": 8,
604 "year": 1971,
605 "genre": "Hard Rock",
606 "created": "2020-01-02T00:00:00"
607 }
608 ]
609 }
610 }
611 }"#;
612
613 let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
614 let album_list = wrapper
615 .subsonic_response
616 .album_list2
617 .expect("album_list2 should be present");
618
619 assert_eq!(album_list.album.len(), 2);
620
621 let first = &album_list.album[0];
622 assert_eq!(first.id, "1");
623 assert_eq!(first.name, "Abbey Road");
624 assert_eq!(first.artist.as_deref(), Some("The Beatles"));
625 assert_eq!(first.artist_id.as_deref(), Some("10"));
626 assert_eq!(first.song_count, Some(17));
627 assert_eq!(first.year, Some(1969));
628
629 let second = &album_list.album[1];
630 assert_eq!(second.id, "2");
631 assert_eq!(second.name, "Led Zeppelin IV");
632 assert_eq!(second.song_count, Some(8));
633 }
634
635 #[test]
638 fn test_auth_params_format() {
639 let client = SubsonicClient::new("http://localhost:4533", "alice", "secret");
640 let params = client.auth_params().unwrap();
641
642 assert!(params.contains_key("u"), "missing 'u' param");
644 assert!(params.contains_key("t"), "missing 't' param");
645 assert!(params.contains_key("s"), "missing 's' param");
646 assert!(params.contains_key("v"), "missing 'v' param");
647 assert!(params.contains_key("c"), "missing 'c' param");
648 assert!(params.contains_key("f"), "missing 'f' param");
649 assert_eq!(params.len(), 6);
650
651 assert_eq!(params["u"], "alice");
652 assert_eq!(params["v"], "1.16.1");
653 assert_eq!(params["c"], "koan");
654 assert_eq!(params["f"], "json");
655 }
656
657 #[test]
658 fn test_auth_params_token_is_md5_of_password_plus_salt() {
659 let client = SubsonicClient::new("http://localhost:4533", "bob", "letmein");
660 let params = client.auth_params().unwrap();
661
662 let salt = ¶ms["s"];
663 let token = ¶ms["t"];
664
665 let expected = format!("{:x}", md5::compute(format!("letmein{}", salt)));
667 assert_eq!(token, &expected);
668 }
669
670 #[test]
671 fn test_auth_params_salt_is_different_each_call() {
672 let client = SubsonicClient::new("http://localhost:4533", "user", "pass");
673 let params1 = client.auth_params().unwrap();
674 let params2 = client.auth_params().unwrap();
675
676 assert_ne!(params1["s"], params2["s"], "salt should be random per call");
679 }
680
681 #[test]
684 fn test_stream_url_has_auth() {
685 let client = SubsonicClient::new("http://myserver:4533", "user", "pass");
686 let url = client.stream_url("track-123").unwrap();
687
688 assert!(url.contains("track-123"), "url must include the track id");
689 assert!(url.contains("u=user"), "url must include username param");
690 assert!(url.contains("v=1.16.1"), "url must include api version");
691 assert!(url.contains("c=koan"), "url must include client name");
692 assert!(url.contains("f=json"), "url must include format param");
693 assert!(url.contains("/rest/stream"), "url must target /rest/stream");
694 assert!(
695 url.starts_with("http://myserver:4533"),
696 "url must use the configured base_url"
697 );
698 }
699
700 #[test]
701 fn test_stream_url_base_url_trailing_slash_normalised() {
702 let client_with_slash = SubsonicClient::new("http://myserver:4533/", "u", "p");
704 let client_no_slash = SubsonicClient::new("http://myserver:4533", "u", "p");
705
706 let url_with = client_with_slash.stream_url("1").unwrap();
707 let url_without = client_no_slash.stream_url("1").unwrap();
708
709 assert!(
711 url_with.contains("/rest/stream"),
712 "should not have double slash"
713 );
714 assert!(!url_with.contains("//rest"), "should not have double slash");
715 assert_eq!(
717 url_with.split('?').next(),
718 url_without.split('?').next(),
719 "path segment should be identical regardless of trailing slash"
720 );
721 }
722
723 #[test]
726 fn test_deserialize_album_full_with_songs() {
727 let json = r#"{
728 "id": "5",
729 "name": "Kind of Blue",
730 "artist": "Miles Davis",
731 "artistId": "20",
732 "year": 1959,
733 "genre": "Jazz",
734 "songCount": 5,
735 "created": "2021-06-01T00:00:00",
736 "song": [
737 {"id": "101", "title": "So What"},
738 {"id": "102", "title": "Freddie Freeloader"},
739 {"id": "103", "title": "Blue in Green"}
740 ]
741 }"#;
742
743 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
744
745 assert_eq!(album.id, "5");
746 assert_eq!(album.name, "Kind of Blue");
747 assert_eq!(album.artist.as_deref(), Some("Miles Davis"));
748 assert_eq!(album.year, Some(1959));
749 assert_eq!(album.song.len(), 3);
750 assert_eq!(album.song[0].title, "So What");
751 assert_eq!(album.song[2].id, "103");
752 }
753
754 #[test]
755 fn test_deserialize_album_full_empty_song_list() {
756 let json = r#"{"id": "9", "name": "No Tracks Yet"}"#;
758
759 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
760
761 assert_eq!(album.id, "9");
762 assert!(album.song.is_empty(), "song list should default to empty");
763 }
764
765 #[test]
768 fn test_deserialize_search_result_mixed() {
769 let json = r#"{
770 "artist": [{"id": "1", "name": "Artist One"}],
771 "album": [{"id": "2", "name": "Album One"}],
772 "song": [{"id": "3", "title": "Song One"}]
773 }"#;
774
775 let result: SubsonicSearchResult = serde_json::from_str(json).unwrap();
776
777 assert_eq!(result.artist.len(), 1);
778 assert_eq!(result.artist[0].name, "Artist One");
779 assert_eq!(result.album.len(), 1);
780 assert_eq!(result.album[0].name, "Album One");
781 assert_eq!(result.song.len(), 1);
782 assert_eq!(result.song[0].title, "Song One");
783 }
784
785 #[test]
786 fn test_deserialize_search_result_defaults_to_empty() {
787 let result: SubsonicSearchResult = serde_json::from_str("{}").unwrap();
789
790 assert!(result.artist.is_empty());
791 assert!(result.album.is_empty());
792 assert!(result.song.is_empty());
793 }
794}