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 mut file = std::fs::File::create(dest)?;
179 let mut downloaded: u64 = 0;
180 let mut buf = [0u8; 64 * 1024]; loop {
182 let n = std::io::Read::read(&mut resp, &mut buf).map_err(SubsonicError::Io)?;
183 if n == 0 {
184 break;
185 }
186 file.write_all(&buf[..n])?;
187 downloaded += n as u64;
188 on_progress(downloaded, total);
189 }
190 file.flush()?;
191 Ok(())
192 }
193
194 pub fn search(&self, query: &str) -> Result<SubsonicSearchResult, SubsonicError> {
196 let resp = self.get_with_params("search3", &[("query", query)])?;
197 Ok(resp.search_result3.unwrap_or_default())
198 }
199
200 pub fn scrobble(&self, track_id: &str) -> Result<(), SubsonicError> {
202 self.get_with_params("scrobble", &[("id", track_id)])?;
203 Ok(())
204 }
205
206 pub fn star(&self, track_id: &str) -> Result<(), SubsonicError> {
208 self.get_with_params("star", &[("id", track_id)])?;
209 Ok(())
210 }
211
212 pub fn unstar(&self, track_id: &str) -> Result<(), SubsonicError> {
214 self.get_with_params("unstar", &[("id", track_id)])?;
215 Ok(())
216 }
217
218 pub fn get_starred(&self) -> Result<Vec<SubsonicSong>, SubsonicError> {
220 let resp = self.get("getStarred2")?;
221 Ok(resp.starred2.map(|s| s.song).unwrap_or_default())
222 }
223
224 pub fn create_share(
227 &self,
228 ids: &[&str],
229 description: Option<&str>,
230 ) -> Result<SubsonicShare, SubsonicError> {
231 let url = format!("{}/rest/createShare", self.base_url);
232 let mut params = self.auth_params();
233 if let Some(desc) = description {
234 params.insert("description".into(), desc.to_string());
235 }
236
237 let mut query: Vec<(String, String)> = params.into_iter().collect();
239 for id in ids {
240 query.push(("id".into(), (*id).to_string()));
241 }
242
243 let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
244
245 let inner = resp.subsonic_response;
246 if inner.status != "ok" {
247 if let Some(err) = inner.error {
248 return Err(SubsonicError::Api {
249 code: err.code,
250 message: err.message,
251 });
252 }
253 return Err(SubsonicError::BadResponse);
254 }
255
256 inner
257 .shares
258 .and_then(|s| s.share.into_iter().next())
259 .ok_or(SubsonicError::BadResponse)
260 }
261
262 pub fn get_similar_songs(
265 &self,
266 song_id: &str,
267 count: usize,
268 ) -> Result<Vec<SubsonicSong>, SubsonicError> {
269 let count_str = count.to_string();
270 let resp = self.get_with_params(
271 "getSimilarSongs2",
272 &[("id", song_id), ("count", &count_str)],
273 )?;
274 Ok(resp.similar_songs2.and_then(|s| s.song).unwrap_or_default())
275 }
276
277 pub fn get_top_songs(
279 &self,
280 artist_name: &str,
281 count: usize,
282 ) -> Result<Vec<SubsonicSong>, SubsonicError> {
283 let count_str = count.to_string();
284 let resp = self.get_with_params(
285 "getTopSongs",
286 &[("artist", artist_name), ("count", &count_str)],
287 )?;
288 Ok(resp.top_songs.and_then(|t| t.song).unwrap_or_default())
289 }
290
291 pub fn base_url(&self) -> &str {
293 &self.base_url
294 }
295}
296
297#[derive(Debug, Deserialize)]
300struct SubsonicResponseWrapper {
301 #[serde(rename = "subsonic-response")]
302 subsonic_response: SubsonicResponse,
303}
304
305#[derive(Debug, Deserialize)]
306#[serde(rename_all = "camelCase")]
307struct SubsonicResponse {
308 status: String,
309 error: Option<SubsonicApiError>,
310 artists: Option<SubsonicArtists>,
311 album: Option<SubsonicAlbumFull>,
312 album_list2: Option<SubsonicAlbumList>,
313 search_result3: Option<SubsonicSearchResult>,
314 starred2: Option<SubsonicStarred>,
315 shares: Option<SubsonicShares>,
316 similar_songs2: Option<SubsonicSimilarSongs>,
317 top_songs: Option<SubsonicTopSongs>,
318}
319
320#[derive(Debug, Deserialize)]
321struct SubsonicApiError {
322 code: i32,
323 message: String,
324}
325
326#[derive(Debug, Deserialize)]
327struct SubsonicArtists {
328 index: Vec<SubsonicArtistIndex>,
329}
330
331#[derive(Debug, Deserialize)]
332struct SubsonicArtistIndex {
333 artist: Vec<SubsonicArtist>,
334}
335
336#[derive(Debug, Clone, Deserialize)]
337#[serde(rename_all = "camelCase")]
338pub struct SubsonicArtist {
339 pub id: String,
340 pub name: String,
341 pub album_count: Option<i32>,
342}
343
344#[derive(Debug, Clone, Deserialize)]
345#[serde(rename_all = "camelCase")]
346pub struct SubsonicAlbum {
347 pub id: String,
348 pub name: String,
349 pub artist: Option<String>,
350 pub artist_id: Option<String>,
351 pub song_count: Option<i32>,
352 pub year: Option<i32>,
353 pub genre: Option<String>,
354 pub created: Option<String>,
355}
356
357#[derive(Debug, Clone, Deserialize)]
358#[serde(rename_all = "camelCase")]
359pub struct SubsonicAlbumFull {
360 pub id: String,
361 pub name: String,
362 pub artist: Option<String>,
363 pub artist_id: Option<String>,
364 pub year: Option<i32>,
365 pub genre: Option<String>,
366 pub song_count: Option<i32>,
367 pub created: Option<String>,
368 #[serde(default)]
369 pub song: Vec<SubsonicSong>,
370}
371
372#[derive(Debug, Clone, Deserialize)]
373#[serde(rename_all = "camelCase")]
374pub struct SubsonicSong {
375 pub id: String,
376 pub title: String,
377 pub album: Option<String>,
378 pub artist: Option<String>,
379 pub track: Option<i32>,
380 pub disc_number: Option<i32>,
381 pub year: Option<i32>,
382 pub genre: Option<String>,
383 pub duration: Option<i64>,
384 pub bit_rate: Option<i32>,
385 pub suffix: Option<String>,
386 pub content_type: Option<String>,
387 pub album_id: Option<String>,
388 pub artist_id: Option<String>,
389}
390
391#[derive(Debug, Deserialize)]
392struct SubsonicAlbumList {
393 #[serde(default)]
394 album: Vec<SubsonicAlbum>,
395}
396
397#[derive(Debug, Default, Deserialize)]
398pub struct SubsonicSearchResult {
399 #[serde(default)]
400 pub artist: Vec<SubsonicArtist>,
401 #[serde(default)]
402 pub album: Vec<SubsonicAlbum>,
403 #[serde(default)]
404 pub song: Vec<SubsonicSong>,
405}
406
407#[derive(Debug, Default, Deserialize)]
408pub struct SubsonicStarred {
409 #[serde(default)]
410 pub song: Vec<SubsonicSong>,
411}
412
413#[derive(Debug, Deserialize)]
414pub struct SubsonicSimilarSongs {
415 pub song: Option<Vec<SubsonicSong>>,
416}
417
418#[derive(Debug, Deserialize)]
419pub struct SubsonicTopSongs {
420 pub song: Option<Vec<SubsonicSong>>,
421}
422
423#[derive(Debug, Deserialize)]
424struct SubsonicShares {
425 #[serde(default)]
426 share: Vec<SubsonicShare>,
427}
428
429#[derive(Debug, Clone, Deserialize)]
430#[serde(rename_all = "camelCase")]
431pub struct SubsonicShare {
432 pub id: String,
433 pub url: Option<String>,
434 pub description: Option<String>,
435 pub username: Option<String>,
436 pub created: Option<String>,
437 pub expires: Option<String>,
438 pub visit_count: Option<i64>,
439}
440
441fn random_salt() -> String {
443 let mut buf = [0u8; 12];
444 getrandom::getrandom(&mut buf).expect("failed to generate random salt");
445 buf.iter().map(|b| format!("{:02x}", b)).collect()
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 #[test]
455 fn test_deserialize_subsonic_song() {
456 let json = r#"{
457 "id": "42",
458 "title": "Space Oddity",
459 "album": "Space Oddity",
460 "artist": "David Bowie",
461 "track": 1,
462 "discNumber": 1,
463 "year": 1969,
464 "genre": "Rock",
465 "duration": 314,
466 "bitRate": 320,
467 "suffix": "mp3",
468 "contentType": "audio/mpeg",
469 "albumId": "7",
470 "artistId": "3"
471 }"#;
472
473 let song: SubsonicSong = serde_json::from_str(json).unwrap();
474
475 assert_eq!(song.id, "42");
476 assert_eq!(song.title, "Space Oddity");
477 assert_eq!(song.album.as_deref(), Some("Space Oddity"));
478 assert_eq!(song.artist.as_deref(), Some("David Bowie"));
479 assert_eq!(song.track, Some(1));
480 assert_eq!(song.disc_number, Some(1));
481 assert_eq!(song.year, Some(1969));
482 assert_eq!(song.genre.as_deref(), Some("Rock"));
483 assert_eq!(song.duration, Some(314));
484 assert_eq!(song.bit_rate, Some(320));
485 assert_eq!(song.suffix.as_deref(), Some("mp3"));
486 assert_eq!(song.content_type.as_deref(), Some("audio/mpeg"));
487 assert_eq!(song.album_id.as_deref(), Some("7"));
488 assert_eq!(song.artist_id.as_deref(), Some("3"));
489 }
490
491 #[test]
492 fn test_deserialize_subsonic_song_optional_fields_absent() {
493 let json = r#"{"id": "99", "title": "Minimal Track"}"#;
495
496 let song: SubsonicSong = serde_json::from_str(json).unwrap();
497
498 assert_eq!(song.id, "99");
499 assert_eq!(song.title, "Minimal Track");
500 assert!(song.album.is_none());
501 assert!(song.artist.is_none());
502 assert!(song.track.is_none());
503 assert!(song.disc_number.is_none());
504 assert!(song.year.is_none());
505 assert!(song.duration.is_none());
506 assert!(song.bit_rate.is_none());
507 }
508
509 #[test]
512 fn test_deserialize_album_list() {
513 let json = r#"{
514 "subsonic-response": {
515 "status": "ok",
516 "version": "1.16.1",
517 "albumList2": {
518 "album": [
519 {
520 "id": "1",
521 "name": "Abbey Road",
522 "artist": "The Beatles",
523 "artistId": "10",
524 "songCount": 17,
525 "year": 1969,
526 "genre": "Rock",
527 "created": "2020-01-01T00:00:00"
528 },
529 {
530 "id": "2",
531 "name": "Led Zeppelin IV",
532 "artist": "Led Zeppelin",
533 "artistId": "11",
534 "songCount": 8,
535 "year": 1971,
536 "genre": "Hard Rock",
537 "created": "2020-01-02T00:00:00"
538 }
539 ]
540 }
541 }
542 }"#;
543
544 let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
545 let album_list = wrapper
546 .subsonic_response
547 .album_list2
548 .expect("album_list2 should be present");
549
550 assert_eq!(album_list.album.len(), 2);
551
552 let first = &album_list.album[0];
553 assert_eq!(first.id, "1");
554 assert_eq!(first.name, "Abbey Road");
555 assert_eq!(first.artist.as_deref(), Some("The Beatles"));
556 assert_eq!(first.artist_id.as_deref(), Some("10"));
557 assert_eq!(first.song_count, Some(17));
558 assert_eq!(first.year, Some(1969));
559
560 let second = &album_list.album[1];
561 assert_eq!(second.id, "2");
562 assert_eq!(second.name, "Led Zeppelin IV");
563 assert_eq!(second.song_count, Some(8));
564 }
565
566 #[test]
569 fn test_auth_params_format() {
570 let client = SubsonicClient::new("http://localhost:4533", "alice", "secret");
571 let params = client.auth_params();
572
573 assert!(params.contains_key("u"), "missing 'u' param");
575 assert!(params.contains_key("t"), "missing 't' param");
576 assert!(params.contains_key("s"), "missing 's' param");
577 assert!(params.contains_key("v"), "missing 'v' param");
578 assert!(params.contains_key("c"), "missing 'c' param");
579 assert!(params.contains_key("f"), "missing 'f' param");
580 assert_eq!(params.len(), 6);
581
582 assert_eq!(params["u"], "alice");
583 assert_eq!(params["v"], "1.16.1");
584 assert_eq!(params["c"], "koan");
585 assert_eq!(params["f"], "json");
586 }
587
588 #[test]
589 fn test_auth_params_token_is_md5_of_password_plus_salt() {
590 let client = SubsonicClient::new("http://localhost:4533", "bob", "letmein");
591 let params = client.auth_params();
592
593 let salt = ¶ms["s"];
594 let token = ¶ms["t"];
595
596 let expected = format!("{:x}", md5::compute(format!("letmein{}", salt)));
598 assert_eq!(token, &expected);
599 }
600
601 #[test]
602 fn test_auth_params_salt_is_different_each_call() {
603 let client = SubsonicClient::new("http://localhost:4533", "user", "pass");
604 let params1 = client.auth_params();
605 let params2 = client.auth_params();
606
607 assert_ne!(params1["s"], params2["s"], "salt should be random per call");
610 }
611
612 #[test]
615 fn test_stream_url_has_auth() {
616 let client = SubsonicClient::new("http://myserver:4533", "user", "pass");
617 let url = client.stream_url("track-123");
618
619 assert!(url.contains("track-123"), "url must include the track id");
620 assert!(url.contains("u=user"), "url must include username param");
621 assert!(url.contains("v=1.16.1"), "url must include api version");
622 assert!(url.contains("c=koan"), "url must include client name");
623 assert!(url.contains("f=json"), "url must include format param");
624 assert!(url.contains("/rest/stream"), "url must target /rest/stream");
625 assert!(
626 url.starts_with("http://myserver:4533"),
627 "url must use the configured base_url"
628 );
629 }
630
631 #[test]
632 fn test_stream_url_base_url_trailing_slash_normalised() {
633 let client_with_slash = SubsonicClient::new("http://myserver:4533/", "u", "p");
635 let client_no_slash = SubsonicClient::new("http://myserver:4533", "u", "p");
636
637 let url_with = client_with_slash.stream_url("1");
638 let url_without = client_no_slash.stream_url("1");
639
640 assert!(
642 url_with.contains("/rest/stream"),
643 "should not have double slash"
644 );
645 assert!(!url_with.contains("//rest"), "should not have double slash");
646 assert_eq!(
648 url_with.split('?').next(),
649 url_without.split('?').next(),
650 "path segment should be identical regardless of trailing slash"
651 );
652 }
653
654 #[test]
657 fn test_deserialize_album_full_with_songs() {
658 let json = r#"{
659 "id": "5",
660 "name": "Kind of Blue",
661 "artist": "Miles Davis",
662 "artistId": "20",
663 "year": 1959,
664 "genre": "Jazz",
665 "songCount": 5,
666 "created": "2021-06-01T00:00:00",
667 "song": [
668 {"id": "101", "title": "So What"},
669 {"id": "102", "title": "Freddie Freeloader"},
670 {"id": "103", "title": "Blue in Green"}
671 ]
672 }"#;
673
674 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
675
676 assert_eq!(album.id, "5");
677 assert_eq!(album.name, "Kind of Blue");
678 assert_eq!(album.artist.as_deref(), Some("Miles Davis"));
679 assert_eq!(album.year, Some(1959));
680 assert_eq!(album.song.len(), 3);
681 assert_eq!(album.song[0].title, "So What");
682 assert_eq!(album.song[2].id, "103");
683 }
684
685 #[test]
686 fn test_deserialize_album_full_empty_song_list() {
687 let json = r#"{"id": "9", "name": "No Tracks Yet"}"#;
689
690 let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
691
692 assert_eq!(album.id, "9");
693 assert!(album.song.is_empty(), "song list should default to empty");
694 }
695
696 #[test]
699 fn test_deserialize_search_result_mixed() {
700 let json = r#"{
701 "artist": [{"id": "1", "name": "Artist One"}],
702 "album": [{"id": "2", "name": "Album One"}],
703 "song": [{"id": "3", "title": "Song One"}]
704 }"#;
705
706 let result: SubsonicSearchResult = serde_json::from_str(json).unwrap();
707
708 assert_eq!(result.artist.len(), 1);
709 assert_eq!(result.artist[0].name, "Artist One");
710 assert_eq!(result.album.len(), 1);
711 assert_eq!(result.album[0].name, "Album One");
712 assert_eq!(result.song.len(), 1);
713 assert_eq!(result.song[0].title, "Song One");
714 }
715
716 #[test]
717 fn test_deserialize_search_result_defaults_to_empty() {
718 let result: SubsonicSearchResult = serde_json::from_str("{}").unwrap();
720
721 assert!(result.artist.is_empty());
722 assert!(result.album.is_empty());
723 assert!(result.song.is_empty());
724 }
725}