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