Skip to main content

koan_core/remote/
client.rs

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/// A Subsonic server and the credentials that sign requests to it.
29///
30/// Kept separate from `SubsonicClient` because constructing that builds two
31/// blocking `reqwest` clients, each carrying its own runtime — doing so from
32/// inside a tokio runtime panics. A caller that only needs a signed URL, such
33/// as koan's own Subsonic proxy, holds this instead.
34#[derive(Debug, Clone, PartialEq, Eq)]
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    /// Build auth query params: u, then p (HTTPS) or t and s, then v, c, f.
51    ///
52    /// Over HTTPS the password goes as `p=enc:<hex>`: a koan server checks
53    /// accounts against an argon2 hash, which token auth cannot be checked
54    /// against. Over plain HTTP that would expose the password, so the salted
55    /// token is sent instead, as every Subsonic server accepts.
56    fn params(&self) -> Result<HashMap<String, String>, SubsonicError> {
57        let mut params = HashMap::new();
58        params.insert("u".into(), self.username.clone());
59        if self.base_url.starts_with("https://") {
60            let hex: String = self.password.bytes().map(|b| format!("{b:02x}")).collect();
61            params.insert("p".into(), format!("enc:{hex}"));
62        } else {
63            let salt = random_salt()?;
64            let token = format!("{:x}", md5::compute(format!("{}{}", self.password, salt)));
65            params.insert("t".into(), token);
66            params.insert("s".into(), salt);
67        }
68        params.insert("v".into(), API_VERSION.into());
69        params.insert("c".into(), CLIENT_NAME.into());
70        params.insert("f".into(), "json".into());
71        Ok(params)
72    }
73
74    /// Build the streaming URL for a track (doesn't make a request).
75    pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
76        let query: String = self
77            .params()?
78            .iter()
79            .map(|(k, v)| format!("{}={}", k, v))
80            .collect::<Vec<_>>()
81            .join("&");
82        Ok(format!(
83            "{}/rest/stream?id={}&{}",
84            self.base_url, track_id, query
85        ))
86    }
87}
88
89/// Subsonic/Navidrome API client.
90///
91/// Holds two HTTP clients with different timeout semantics: `http` bounds a
92/// whole JSON request, which is right for small API responses read in one go;
93/// `downloader` bounds only connect and per-read stalls, so a large track on a
94/// slow link is never cut off for taking too long overall.
95pub struct SubsonicClient {
96    auth: SubsonicAuth,
97    http: reqwest::blocking::Client,
98    downloader: reqwest::blocking::Client,
99}
100
101impl SubsonicClient {
102    pub fn new(base_url: &str, username: &str, password: &str) -> Self {
103        Self::from_auth(SubsonicAuth::new(base_url, username, password))
104    }
105
106    pub fn from_auth(auth: SubsonicAuth) -> Self {
107        Self {
108            auth,
109            http: download::api_client().unwrap_or_else(|e| {
110                log::warn!("falling back to default HTTP client: {}", e);
111                reqwest::blocking::Client::new()
112            }),
113            downloader: download::download_client().unwrap_or_else(|e| {
114                log::warn!("falling back to default download client: {}", e);
115                reqwest::blocking::Client::new()
116            }),
117        }
118    }
119
120    fn auth_params(&self) -> Result<HashMap<String, String>, SubsonicError> {
121        self.auth.params()
122    }
123
124    /// Make a GET request to a Subsonic API endpoint.
125    fn get(&self, endpoint: &str) -> Result<SubsonicResponse, SubsonicError> {
126        self.get_with_params(endpoint, &[])
127    }
128
129    fn get_with_params(
130        &self,
131        endpoint: &str,
132        extra: &[(&str, &str)],
133    ) -> Result<SubsonicResponse, SubsonicError> {
134        let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
135        let mut params = self.auth_params()?;
136        for (k, v) in extra {
137            params.insert((*k).to_string(), (*v).to_string());
138        }
139
140        let resp: SubsonicResponseWrapper = self.http.get(&url).query(&params).send()?.json()?;
141
142        let inner = resp.subsonic_response;
143        if inner.status != "ok" {
144            if let Some(err) = inner.error {
145                return Err(SubsonicError::Api {
146                    code: err.code,
147                    message: err.message,
148                });
149            }
150            return Err(SubsonicError::BadResponse);
151        }
152
153        Ok(inner)
154    }
155
156    /// Detect a Subsonic error returned from an endpoint that should have sent
157    /// binary data.
158    ///
159    /// Subsonic signals failure with HTTP 200 and a JSON or XML error body, so
160    /// checking the status code proves nothing here — without this, an error
161    /// response gets written to disk as if it were audio.
162    fn reject_error_body(resp: &reqwest::blocking::Response) -> Result<(), SubsonicError> {
163        let is_document = resp
164            .headers()
165            .get(reqwest::header::CONTENT_TYPE)
166            .and_then(|v| v.to_str().ok())
167            .is_some_and(|ct| ct.contains("json") || ct.contains("xml"));
168        if is_document {
169            return Err(SubsonicError::BadResponse);
170        }
171        if !resp.status().is_success() {
172            return Err(SubsonicError::BadResponse);
173        }
174        Ok(())
175    }
176
177    /// Fetch cover art bytes for a song or album ID.
178    ///
179    /// Returns the raw image rather than a parsed response — `getCoverArt`
180    /// answers with image data, not JSON, so it can't go through `get()`.
181    /// `size` requests a square thumbnail; omit it for the original.
182    pub fn get_cover_art(&self, id: &str, size: Option<u32>) -> Result<Vec<u8>, SubsonicError> {
183        let url = format!("{}/rest/getCoverArt", self.base_url());
184        let mut params = self.auth_params()?;
185        params.insert("id".into(), id.to_string());
186        if let Some(px) = size {
187            params.insert("size".into(), px.to_string());
188        }
189
190        let resp = self.http.get(&url).query(&params).send()?;
191        Self::reject_error_body(&resp)?;
192        Ok(resp.bytes()?.to_vec())
193    }
194
195    /// Ping the server — verify connection and credentials.
196    pub fn ping(&self) -> Result<(), SubsonicError> {
197        self.get("ping")?;
198        Ok(())
199    }
200
201    /// Get all artists (indexed).
202    pub fn get_artists(&self) -> Result<Vec<SubsonicArtist>, SubsonicError> {
203        let resp = self.get("getArtists")?;
204        let artists_data = resp.artists.ok_or(SubsonicError::BadResponse)?;
205        let mut all = Vec::new();
206        for index in artists_data.index {
207            all.extend(index.artist);
208        }
209        Ok(all)
210    }
211
212    /// Get an album by ID, including its tracks.
213    pub fn get_album(&self, id: &str) -> Result<SubsonicAlbumFull, SubsonicError> {
214        let resp = self.get_with_params("getAlbum", &[("id", id)])?;
215        resp.album.ok_or(SubsonicError::BadResponse)
216    }
217
218    /// Get a paginated list of albums.
219    pub fn get_album_list(
220        &self,
221        list_type: &str,
222        size: u32,
223        offset: u32,
224    ) -> Result<Vec<SubsonicAlbum>, SubsonicError> {
225        let size_str = size.to_string();
226        let offset_str = offset.to_string();
227        let resp = self.get_with_params(
228            "getAlbumList2",
229            &[
230                ("type", list_type),
231                ("size", &size_str),
232                ("offset", &offset_str),
233            ],
234        )?;
235        Ok(resp.album_list2.map(|al| al.album).unwrap_or_default())
236    }
237
238    /// Build the streaming URL for a track (doesn't make a request).
239    pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
240        self.auth.stream_url(track_id)
241    }
242
243    /// Stream URL without auth params — safe for database storage.
244    pub fn stream_url_template(&self, track_id: &str) -> String {
245        format!("{}/rest/stream?id={}", self.auth.base_url, track_id)
246    }
247
248    /// Download a track to a local path.
249    pub fn download(&self, track_id: &str, dest: &Path) -> Result<(), SubsonicError> {
250        self.download_with_progress(track_id, dest, |_, _| {})
251    }
252
253    /// Download a track with progress reporting.
254    ///
255    /// The callback receives `(bytes_downloaded, total_bytes)`; total is 0 when
256    /// the server sends no Content-Length, and the count restarts from zero if
257    /// an attempt is retried. `dest` only appears once the file is complete.
258    pub fn download_with_progress(
259        &self,
260        track_id: &str,
261        dest: &Path,
262        on_progress: impl Fn(u64, u64),
263    ) -> Result<(), SubsonicError> {
264        self.fetch_to_file("download", track_id, dest, on_progress)
265    }
266
267    /// Fetch a track through `/rest/stream` instead of `/rest/download`.
268    ///
269    /// `download` returns the untranscoded original and is what library sync
270    /// wants from Navidrome. koan's own server implements only `stream`, so
271    /// that is how the remote bridge pulls audio from a `koan serve` instance.
272    pub fn stream_to_file(
273        &self,
274        track_id: &str,
275        dest: &Path,
276        on_progress: impl Fn(u64, u64),
277    ) -> Result<(), SubsonicError> {
278        self.fetch_to_file("stream", track_id, dest, on_progress)
279    }
280
281    fn fetch_to_file(
282        &self,
283        endpoint: &str,
284        track_id: &str,
285        dest: &Path,
286        on_progress: impl Fn(u64, u64),
287    ) -> Result<(), SubsonicError> {
288        let url = format!("{}/rest/{}", self.auth.base_url, endpoint);
289        download::download_with_retries(
290            dest,
291            download::DEFAULT_ATTEMPTS,
292            || {
293                // Fresh auth params per attempt — the salt must not be replayed.
294                let mut params = self
295                    .auth_params()
296                    .map_err(|e| download::DownloadError::Request(e.to_string()))?;
297                params.insert("id".into(), track_id.to_string());
298                Ok(self.downloader.get(&url).query(&params))
299            },
300            on_progress,
301        )?;
302        Ok(())
303    }
304
305    /// One page of every song on the server, `size` from `offset`.
306    ///
307    /// An empty `search3` query lists the whole library on OpenSubsonic
308    /// servers (Navidrome, koan). Older servers answer it with nothing or an
309    /// error, which is the caller's cue to walk albums one at a time instead.
310    pub fn all_songs_page(
311        &self,
312        size: u32,
313        offset: u32,
314    ) -> Result<Vec<SubsonicSong>, SubsonicError> {
315        let size = size.to_string();
316        let offset = offset.to_string();
317        let resp = self.get_with_params(
318            "search3",
319            &[
320                ("query", ""),
321                ("artistCount", "0"),
322                ("albumCount", "0"),
323                ("songCount", &size),
324                ("songOffset", &offset),
325            ],
326        )?;
327        Ok(resp.search_result3.map(|r| r.song).unwrap_or_default())
328    }
329
330    /// How many songs the server says it has, from `getScanStatus`. `None`
331    /// where the server does not count.
332    pub fn song_count(&self) -> Result<Option<u64>, SubsonicError> {
333        let resp = self.get("getScanStatus")?;
334        Ok(resp.scan_status.and_then(|s| s.count))
335    }
336
337    /// Search for tracks/albums/artists.
338    pub fn search(&self, query: &str) -> Result<SubsonicSearchResult, SubsonicError> {
339        let resp = self.get_with_params("search3", &[("query", query)])?;
340        Ok(resp.search_result3.unwrap_or_default())
341    }
342
343    /// Report a play (scrobble).
344    pub fn scrobble(&self, track_id: &str) -> Result<(), SubsonicError> {
345        self.get_with_params("scrobble", &[("id", track_id)])?;
346        Ok(())
347    }
348
349    /// Star (favourite) a track on the server.
350    pub fn star(&self, track_id: &str) -> Result<(), SubsonicError> {
351        self.get_with_params("star", &[("id", track_id)])?;
352        Ok(())
353    }
354
355    /// Unstar (unfavourite) a track on the server.
356    pub fn unstar(&self, track_id: &str) -> Result<(), SubsonicError> {
357        self.get_with_params("unstar", &[("id", track_id)])?;
358        Ok(())
359    }
360
361    /// Get all starred (favourite) songs from the server.
362    pub fn get_starred(&self) -> Result<Vec<SubsonicSong>, SubsonicError> {
363        let resp = self.get("getStarred2")?;
364        Ok(resp.starred2.map(|s| s.song).unwrap_or_default())
365    }
366
367    /// Everything the server has starred: songs, albums and artists.
368    ///
369    /// Subsonic returns all three from one call, so asking for songs alone
370    /// leaves a starred album invisible to us for no saving.
371    pub fn get_starred_all(&self) -> Result<SubsonicStarred, SubsonicError> {
372        let resp = self.get("getStarred2")?;
373        Ok(resp.starred2.unwrap_or_default())
374    }
375
376    /// Star an album. Subsonic keys this off a different parameter to a song —
377    /// `id` would be read as a track and silently star nothing.
378    pub fn star_album(&self, album_id: &str) -> Result<(), SubsonicError> {
379        self.get_with_params("star", &[("albumId", album_id)])?;
380        Ok(())
381    }
382
383    pub fn unstar_album(&self, album_id: &str) -> Result<(), SubsonicError> {
384        self.get_with_params("unstar", &[("albumId", album_id)])?;
385        Ok(())
386    }
387
388    pub fn star_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
389        self.get_with_params("star", &[("artistId", artist_id)])?;
390        Ok(())
391    }
392
393    pub fn unstar_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
394        self.get_with_params("unstar", &[("artistId", artist_id)])?;
395        Ok(())
396    }
397
398    /// Create a sharing link for one or more resources (songs, albums, etc).
399    /// Returns the created share including its ID which forms the public URL.
400    pub fn create_share(
401        &self,
402        ids: &[&str],
403        description: Option<&str>,
404    ) -> Result<SubsonicShare, SubsonicError> {
405        let url = format!("{}/rest/createShare", self.auth.base_url);
406        let mut params = self.auth_params()?;
407        if let Some(desc) = description {
408            params.insert("description".into(), desc.to_string());
409        }
410
411        // Subsonic API takes `id` as a repeated param for multiple resources.
412        let mut query: Vec<(String, String)> = params.into_iter().collect();
413        for id in ids {
414            query.push(("id".into(), (*id).to_string()));
415        }
416
417        let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
418
419        let inner = resp.subsonic_response;
420        if inner.status != "ok" {
421            if let Some(err) = inner.error {
422                return Err(SubsonicError::Api {
423                    code: err.code,
424                    message: err.message,
425                });
426            }
427            return Err(SubsonicError::BadResponse);
428        }
429
430        inner
431            .shares
432            .and_then(|s| s.share.into_iter().next())
433            .ok_or(SubsonicError::BadResponse)
434    }
435
436    /// Get similar songs for a track (Subsonic getSimilarSongs2 endpoint).
437    /// Returns up to `count` similar songs based on the server's algorithm.
438    pub fn get_similar_songs(
439        &self,
440        song_id: &str,
441        count: usize,
442    ) -> Result<Vec<SubsonicSong>, SubsonicError> {
443        let count_str = count.to_string();
444        let resp = self.get_with_params(
445            "getSimilarSongs2",
446            &[("id", song_id), ("count", &count_str)],
447        )?;
448        Ok(resp.similar_songs2.and_then(|s| s.song).unwrap_or_default())
449    }
450
451    /// Get top songs for an artist by name.
452    pub fn get_top_songs(
453        &self,
454        artist_name: &str,
455        count: usize,
456    ) -> Result<Vec<SubsonicSong>, SubsonicError> {
457        let count_str = count.to_string();
458        let resp = self.get_with_params(
459            "getTopSongs",
460            &[("artist", artist_name), ("count", &count_str)],
461        )?;
462        Ok(resp.top_songs.and_then(|t| t.song).unwrap_or_default())
463    }
464
465    // --- Playlists ---------------------------------------------------------
466
467    /// Every playlist the server will show this user, without their contents.
468    pub fn get_playlists(&self) -> Result<Vec<SubsonicPlaylist>, SubsonicError> {
469        let resp = self.get("getPlaylists")?;
470        Ok(resp.playlists.map(|p| p.playlist).unwrap_or_default())
471    }
472
473    /// One playlist, with its songs in order.
474    pub fn get_playlist(&self, id: &str) -> Result<SubsonicPlaylistFull, SubsonicError> {
475        let resp = self.get_with_params("getPlaylist", &[("id", id)])?;
476        resp.playlist.ok_or(SubsonicError::BadResponse)
477    }
478
479    /// Create a playlist, or replace an existing one's contents wholesale.
480    ///
481    /// `createPlaylist` is the only Subsonic call that can set a playlist's
482    /// order: `updatePlaylist` appends and removes by index, which cannot
483    /// express a reorder. Passing `playlist_id` turns this into "these songs,
484    /// in this order, from now on", which is exactly what koan has after any
485    /// edit — so every push takes this path and there is one way for the two
486    /// sides to disagree instead of five.
487    pub fn create_playlist(
488        &self,
489        playlist_id: Option<&str>,
490        name: &str,
491        song_ids: &[String],
492    ) -> Result<Option<SubsonicPlaylistFull>, SubsonicError> {
493        let url = format!("{}/rest/createPlaylist", self.auth.base_url);
494        let mut params = self.auth_params()?;
495        match playlist_id {
496            Some(id) => {
497                params.insert("playlistId".into(), id.to_string());
498                // Navidrome keeps the stored name when updating, but a rename
499                // that happened offline has to travel somehow.
500                params.insert("name".into(), name.to_string());
501            }
502            None => {
503                params.insert("name".into(), name.to_string());
504            }
505        }
506
507        // Repeated `songId`, in order — that order is the playlist.
508        let mut query: Vec<(String, String)> = params.into_iter().collect();
509        for id in song_ids {
510            query.push(("songId".into(), id.clone()));
511        }
512
513        let resp: SubsonicResponseWrapper = self.http.get(&url).query(&query).send()?.json()?;
514        let inner = resp.subsonic_response;
515        if inner.status != "ok" {
516            if let Some(err) = inner.error {
517                return Err(SubsonicError::Api {
518                    code: err.code,
519                    message: err.message,
520                });
521            }
522            return Err(SubsonicError::BadResponse);
523        }
524        // Servers before 1.14.0 answer with an empty body, so an absent
525        // playlist here is not an error — only a caller that needed the new id
526        // has a problem, and it says so itself.
527        Ok(inner.playlist)
528    }
529
530    /// Change what can be changed without touching the song list.
531    pub fn update_playlist(
532        &self,
533        id: &str,
534        name: Option<&str>,
535        comment: Option<&str>,
536        public: Option<bool>,
537    ) -> Result<(), SubsonicError> {
538        let mut extra: Vec<(&str, String)> = vec![("playlistId", id.to_string())];
539        if let Some(name) = name {
540            extra.push(("name", name.to_string()));
541        }
542        if let Some(comment) = comment {
543            extra.push(("comment", comment.to_string()));
544        }
545        if let Some(public) = public {
546            extra.push(("public", public.to_string()));
547        }
548        let borrowed: Vec<(&str, &str)> = extra.iter().map(|(k, v)| (*k, v.as_str())).collect();
549        self.get_with_params("updatePlaylist", &borrowed)?;
550        Ok(())
551    }
552
553    pub fn delete_playlist(&self, id: &str) -> Result<(), SubsonicError> {
554        self.get_with_params("deletePlaylist", &[("id", id)])?;
555        Ok(())
556    }
557
558    /// The configured server base URL (for constructing share links etc).
559    pub fn base_url(&self) -> &str {
560        &self.auth.base_url
561    }
562}
563
564// --- Response types ---
565
566#[derive(Debug, Deserialize)]
567struct SubsonicResponseWrapper {
568    #[serde(rename = "subsonic-response")]
569    subsonic_response: SubsonicResponse,
570}
571
572#[derive(Debug, Deserialize)]
573#[serde(rename_all = "camelCase")]
574struct SubsonicResponse {
575    status: String,
576    error: Option<SubsonicApiError>,
577    artists: Option<SubsonicArtists>,
578    album: Option<SubsonicAlbumFull>,
579    album_list2: Option<SubsonicAlbumList>,
580    search_result3: Option<SubsonicSearchResult>,
581    starred2: Option<SubsonicStarred>,
582    shares: Option<SubsonicShares>,
583    similar_songs2: Option<SubsonicSimilarSongs>,
584    top_songs: Option<SubsonicTopSongs>,
585    playlists: Option<SubsonicPlaylists>,
586    playlist: Option<SubsonicPlaylistFull>,
587    scan_status: Option<SubsonicScanStatus>,
588}
589
590#[derive(Debug, Deserialize)]
591struct SubsonicScanStatus {
592    count: Option<u64>,
593}
594
595#[derive(Debug, Deserialize)]
596struct SubsonicApiError {
597    code: i32,
598    message: String,
599}
600
601#[derive(Debug, Deserialize)]
602struct SubsonicArtists {
603    index: Vec<SubsonicArtistIndex>,
604}
605
606#[derive(Debug, Deserialize)]
607struct SubsonicArtistIndex {
608    artist: Vec<SubsonicArtist>,
609}
610
611#[derive(Debug, Clone, Deserialize)]
612#[serde(rename_all = "camelCase")]
613pub struct SubsonicArtist {
614    pub id: String,
615    pub name: String,
616    pub album_count: Option<i32>,
617    // OpenSubsonic. Both arrive in `getArtists`, so keeping them costs no
618    // extra request.
619    #[serde(default, deserialize_with = "non_empty")]
620    pub music_brainz_id: Option<String>,
621    #[serde(default, deserialize_with = "non_empty")]
622    pub sort_name: Option<String>,
623}
624
625#[derive(Debug, Clone, Deserialize)]
626#[serde(rename_all = "camelCase")]
627pub struct SubsonicAlbum {
628    pub id: String,
629    pub name: String,
630    pub artist: Option<String>,
631    pub artist_id: Option<String>,
632    pub song_count: Option<i32>,
633    pub year: Option<i32>,
634    pub genre: Option<String>,
635    pub created: Option<String>,
636    // OpenSubsonic. All of these arrive in `getAlbumList2`, which the sync
637    // already pages through.
638    #[serde(default, deserialize_with = "non_empty")]
639    pub music_brainz_id: Option<String>,
640    #[serde(default, deserialize_with = "non_empty")]
641    pub sort_name: Option<String>,
642    #[serde(default)]
643    pub record_labels: Vec<SubsonicName>,
644}
645
646/// A bare `{"name": "..."}` object. The server uses this shape for record
647/// labels, genres and moods alike.
648#[derive(Debug, Clone, Deserialize)]
649pub struct SubsonicName {
650    pub name: String,
651}
652
653#[derive(Debug, Clone, Deserialize)]
654#[serde(rename_all = "camelCase")]
655pub struct SubsonicAlbumFull {
656    pub id: String,
657    pub name: String,
658    pub artist: Option<String>,
659    pub artist_id: Option<String>,
660    pub year: Option<i32>,
661    pub genre: Option<String>,
662    pub song_count: Option<i32>,
663    pub created: Option<String>,
664    #[serde(default, deserialize_with = "non_empty")]
665    pub music_brainz_id: Option<String>,
666    #[serde(default, deserialize_with = "non_empty")]
667    pub sort_name: Option<String>,
668    #[serde(default)]
669    pub record_labels: Vec<SubsonicName>,
670    #[serde(default)]
671    pub song: Vec<SubsonicSong>,
672}
673
674#[derive(Debug, Clone, Deserialize)]
675#[serde(rename_all = "camelCase")]
676pub struct SubsonicSong {
677    pub id: String,
678    pub title: String,
679    pub album: Option<String>,
680    pub artist: Option<String>,
681    pub track: Option<i32>,
682    pub disc_number: Option<i32>,
683    pub year: Option<i32>,
684    pub genre: Option<String>,
685    pub duration: Option<i64>,
686    pub bit_rate: Option<i32>,
687    pub suffix: Option<String>,
688    pub content_type: Option<String>,
689    pub album_id: Option<String>,
690    pub artist_id: Option<String>,
691    // OpenSubsonic. Absent on a plain Subsonic server, which is why they are
692    // Options rather than defaults — a missing sample rate is not 0 Hz.
693    pub sampling_rate: Option<i32>,
694    pub bit_depth: Option<i32>,
695    pub channel_count: Option<i32>,
696    #[serde(default, deserialize_with = "non_empty")]
697    pub music_brainz_id: Option<String>,
698}
699
700#[derive(Debug, Deserialize)]
701struct SubsonicAlbumList {
702    #[serde(default)]
703    album: Vec<SubsonicAlbum>,
704}
705
706#[derive(Debug, Default, Deserialize)]
707pub struct SubsonicSearchResult {
708    #[serde(default)]
709    pub artist: Vec<SubsonicArtist>,
710    #[serde(default)]
711    pub album: Vec<SubsonicAlbum>,
712    #[serde(default)]
713    pub song: Vec<SubsonicSong>,
714}
715
716#[derive(Debug, Default, Deserialize)]
717pub struct SubsonicStarred {
718    #[serde(default)]
719    pub song: Vec<SubsonicSong>,
720    #[serde(default)]
721    pub album: Vec<SubsonicAlbum>,
722    #[serde(default)]
723    pub artist: Vec<SubsonicArtist>,
724}
725
726#[derive(Debug, Deserialize)]
727pub struct SubsonicSimilarSongs {
728    pub song: Option<Vec<SubsonicSong>>,
729}
730
731#[derive(Debug, Deserialize)]
732pub struct SubsonicTopSongs {
733    pub song: Option<Vec<SubsonicSong>>,
734}
735
736#[derive(Debug, Default, Deserialize)]
737struct SubsonicPlaylists {
738    #[serde(default)]
739    playlist: Vec<SubsonicPlaylist>,
740}
741
742/// A playlist as the server describes it, without its songs.
743#[derive(Debug, Clone, Deserialize)]
744#[serde(rename_all = "camelCase")]
745pub struct SubsonicPlaylist {
746    pub id: String,
747    pub name: String,
748    pub comment: Option<String>,
749    pub owner: Option<String>,
750    #[serde(default)]
751    pub public: bool,
752    pub song_count: Option<i64>,
753    pub duration: Option<i64>,
754    pub created: Option<String>,
755    pub changed: Option<String>,
756}
757
758#[derive(Debug, Clone, Deserialize)]
759#[serde(rename_all = "camelCase")]
760pub struct SubsonicPlaylistFull {
761    #[serde(flatten)]
762    pub playlist: SubsonicPlaylist,
763    #[serde(default)]
764    pub entry: Vec<SubsonicSong>,
765}
766
767#[derive(Debug, Deserialize)]
768struct SubsonicShares {
769    #[serde(default)]
770    share: Vec<SubsonicShare>,
771}
772
773#[derive(Debug, Clone, Deserialize)]
774#[serde(rename_all = "camelCase")]
775pub struct SubsonicShare {
776    pub id: String,
777    pub url: Option<String>,
778    pub description: Option<String>,
779    pub username: Option<String>,
780    pub created: Option<String>,
781    pub expires: Option<String>,
782    pub visit_count: Option<i64>,
783}
784
785/// An empty string as absent. OpenSubsonic servers send every field they
786/// support, empty where there is no value — koan's own sends
787/// `musicBrainzId: ""` for an untagged track. Kept as `Some("")`, that id
788/// matches every other untagged track: the MusicBrainz dedup paired unrelated
789/// tracks on it and scanned the whole table per insert doing so, and the album
790/// enrichment wrote `""` over the missing id.
791fn non_empty<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
792    Ok(Option::<String>::deserialize(d)?.filter(|s| !s.is_empty()))
793}
794
795/// Generate a random hex salt string for Subsonic auth.
796///
797/// The salt goes on the wire next to `md5(password + salt)`, so it has to be
798/// unpredictable — a clock- or counter-derived fallback would make the token
799/// precomputable from a captured exchange. A request without OS entropy fails
800/// rather than authenticating weakly.
801fn random_salt() -> Result<String, getrandom::Error> {
802    let mut buf = [0u8; 12];
803    getrandom::fill(&mut buf)?;
804    Ok(buf.iter().map(|b| format!("{:02x}", b)).collect())
805}
806
807#[cfg(test)]
808mod tests {
809    use super::*;
810
811    // --- SubsonicSong deserialization ---
812
813    #[test]
814    fn test_deserialize_subsonic_song() {
815        let json = r#"{
816            "id": "42",
817            "title": "Space Oddity",
818            "album": "Space Oddity",
819            "artist": "David Bowie",
820            "track": 1,
821            "discNumber": 1,
822            "year": 1969,
823            "genre": "Rock",
824            "duration": 314,
825            "bitRate": 320,
826            "suffix": "mp3",
827            "contentType": "audio/mpeg",
828            "albumId": "7",
829            "artistId": "3"
830        }"#;
831
832        let song: SubsonicSong = serde_json::from_str(json).unwrap();
833
834        assert_eq!(song.id, "42");
835        assert_eq!(song.title, "Space Oddity");
836        assert_eq!(song.album.as_deref(), Some("Space Oddity"));
837        assert_eq!(song.artist.as_deref(), Some("David Bowie"));
838        assert_eq!(song.track, Some(1));
839        assert_eq!(song.disc_number, Some(1));
840        assert_eq!(song.year, Some(1969));
841        assert_eq!(song.genre.as_deref(), Some("Rock"));
842        assert_eq!(song.duration, Some(314));
843        assert_eq!(song.bit_rate, Some(320));
844        assert_eq!(song.suffix.as_deref(), Some("mp3"));
845        assert_eq!(song.content_type.as_deref(), Some("audio/mpeg"));
846        assert_eq!(song.album_id.as_deref(), Some("7"));
847        assert_eq!(song.artist_id.as_deref(), Some("3"));
848    }
849
850    /// An OpenSubsonic server reports the figures that make a track's quality
851    /// legible. Ignoring them left every remote-only track with no sample rate
852    /// and no bit depth at all.
853    #[test]
854    fn opensubsonic_quality_fields_are_read() {
855        let json = r#"{
856            "id": "000XtGC7jsWEbOjDsZi4Xw",
857            "title": "Anguish",
858            "suffix": "flac",
859            "bitRate": 913,
860            "samplingRate": 44100,
861            "bitDepth": 16,
862            "channelCount": 2
863        }"#;
864
865        let song: SubsonicSong = serde_json::from_str(json).unwrap();
866
867        assert_eq!(song.sampling_rate, Some(44100));
868        assert_eq!(song.bit_depth, Some(16));
869        assert_eq!(song.channel_count, Some(2));
870    }
871
872    /// A plain Subsonic server omits them, and a missing sample rate is not
873    /// 0 Hz — the fields have to stay absent rather than default.
874    #[test]
875    fn a_plain_subsonic_song_has_no_quality_figures() {
876        let json = r#"{"id": "1", "title": "Track", "bitRate": 320}"#;
877        let song: SubsonicSong = serde_json::from_str(json).unwrap();
878
879        assert_eq!(song.sampling_rate, None);
880        assert_eq!(song.bit_depth, None);
881        assert_eq!(song.channel_count, None);
882    }
883
884    #[test]
885    fn test_deserialize_subsonic_song_optional_fields_absent() {
886        // Only the required fields (id, title) — all Option fields should be None.
887        let json = r#"{"id": "99", "title": "Minimal Track"}"#;
888
889        let song: SubsonicSong = serde_json::from_str(json).unwrap();
890
891        assert_eq!(song.id, "99");
892        assert_eq!(song.title, "Minimal Track");
893        assert!(song.album.is_none());
894        assert!(song.artist.is_none());
895        assert!(song.track.is_none());
896        assert!(song.disc_number.is_none());
897        assert!(song.year.is_none());
898        assert!(song.duration.is_none());
899        assert!(song.bit_rate.is_none());
900    }
901
902    // --- SubsonicAlbum deserialization ---
903
904    #[test]
905    fn test_deserialize_album_list() {
906        let json = r#"{
907            "subsonic-response": {
908                "status": "ok",
909                "version": "1.16.1",
910                "albumList2": {
911                    "album": [
912                        {
913                            "id": "1",
914                            "name": "Abbey Road",
915                            "artist": "The Beatles",
916                            "artistId": "10",
917                            "songCount": 17,
918                            "year": 1969,
919                            "genre": "Rock",
920                            "created": "2020-01-01T00:00:00"
921                        },
922                        {
923                            "id": "2",
924                            "name": "Led Zeppelin IV",
925                            "artist": "Led Zeppelin",
926                            "artistId": "11",
927                            "songCount": 8,
928                            "year": 1971,
929                            "genre": "Hard Rock",
930                            "created": "2020-01-02T00:00:00"
931                        }
932                    ]
933                }
934            }
935        }"#;
936
937        let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
938        let album_list = wrapper
939            .subsonic_response
940            .album_list2
941            .expect("album_list2 should be present");
942
943        assert_eq!(album_list.album.len(), 2);
944
945        let first = &album_list.album[0];
946        assert_eq!(first.id, "1");
947        assert_eq!(first.name, "Abbey Road");
948        assert_eq!(first.artist.as_deref(), Some("The Beatles"));
949        assert_eq!(first.artist_id.as_deref(), Some("10"));
950        assert_eq!(first.song_count, Some(17));
951        assert_eq!(first.year, Some(1969));
952
953        let second = &album_list.album[1];
954        assert_eq!(second.id, "2");
955        assert_eq!(second.name, "Led Zeppelin IV");
956        assert_eq!(second.song_count, Some(8));
957    }
958
959    // --- SubsonicClient auth params ---
960
961    #[test]
962    fn test_auth_params_format() {
963        let client = SubsonicClient::new("http://localhost:4533", "alice", "secret");
964        let params = client.auth_params().unwrap();
965
966        // Must contain exactly these six keys.
967        assert!(params.contains_key("u"), "missing 'u' param");
968        assert!(params.contains_key("t"), "missing 't' param");
969        assert!(params.contains_key("s"), "missing 's' param");
970        assert!(params.contains_key("v"), "missing 'v' param");
971        assert!(params.contains_key("c"), "missing 'c' param");
972        assert!(params.contains_key("f"), "missing 'f' param");
973        assert_eq!(params.len(), 6);
974
975        assert_eq!(params["u"], "alice");
976        assert_eq!(params["v"], "1.16.1");
977        assert_eq!(params["c"], "koan");
978        assert_eq!(params["f"], "json");
979    }
980
981    #[test]
982    fn test_auth_params_over_https_send_the_hex_password() {
983        let client = SubsonicClient::new("https://koan.example", "alice", "hi");
984        let params = client.auth_params().unwrap();
985        assert_eq!(params["p"], "enc:6869");
986        assert!(!params.contains_key("t") && !params.contains_key("s"));
987    }
988
989    #[test]
990    fn test_auth_params_token_is_md5_of_password_plus_salt() {
991        let client = SubsonicClient::new("http://localhost:4533", "bob", "letmein");
992        let params = client.auth_params().unwrap();
993
994        let salt = &params["s"];
995        let token = &params["t"];
996
997        // The token must equal md5(password + salt).
998        let expected = format!("{:x}", md5::compute(format!("letmein{}", salt)));
999        assert_eq!(token, &expected);
1000    }
1001
1002    #[test]
1003    fn test_auth_params_salt_is_different_each_call() {
1004        let client = SubsonicClient::new("http://localhost:4533", "user", "pass");
1005        let params1 = client.auth_params().unwrap();
1006        let params2 = client.auth_params().unwrap();
1007
1008        // Salts should differ across calls (random); tokens will differ too.
1009        // There is a negligible probability they collide — acceptable in tests.
1010        assert_ne!(params1["s"], params2["s"], "salt should be random per call");
1011    }
1012
1013    // --- stream_url ---
1014
1015    #[test]
1016    fn test_stream_url_has_auth() {
1017        let client = SubsonicClient::new("http://myserver:4533", "user", "pass");
1018        let url = client.stream_url("track-123").unwrap();
1019
1020        assert!(url.contains("track-123"), "url must include the track id");
1021        assert!(url.contains("u=user"), "url must include username param");
1022        assert!(url.contains("v=1.16.1"), "url must include api version");
1023        assert!(url.contains("c=koan"), "url must include client name");
1024        assert!(url.contains("f=json"), "url must include format param");
1025        assert!(url.contains("/rest/stream"), "url must target /rest/stream");
1026        assert!(
1027            url.starts_with("http://myserver:4533"),
1028            "url must use the configured base_url"
1029        );
1030    }
1031
1032    #[test]
1033    fn test_stream_url_base_url_trailing_slash_normalised() {
1034        // SubsonicClient::new strips trailing slashes from base_url.
1035        let client_with_slash = SubsonicClient::new("http://myserver:4533/", "u", "p");
1036        let client_no_slash = SubsonicClient::new("http://myserver:4533", "u", "p");
1037
1038        let url_with = client_with_slash.stream_url("1").unwrap();
1039        let url_without = client_no_slash.stream_url("1").unwrap();
1040
1041        // Both should produce the same path prefix (no double slash).
1042        assert!(
1043            url_with.contains("/rest/stream"),
1044            "should not have double slash"
1045        );
1046        assert!(!url_with.contains("//rest"), "should not have double slash");
1047        // Both base URLs normalise to the same path structure.
1048        assert_eq!(
1049            url_with.split('?').next(),
1050            url_without.split('?').next(),
1051            "path segment should be identical regardless of trailing slash"
1052        );
1053    }
1054
1055    // --- SubsonicAlbumFull deserialization ---
1056
1057    #[test]
1058    fn test_deserialize_album_full_with_songs() {
1059        let json = r#"{
1060            "id": "5",
1061            "name": "Kind of Blue",
1062            "artist": "Miles Davis",
1063            "artistId": "20",
1064            "year": 1959,
1065            "genre": "Jazz",
1066            "songCount": 5,
1067            "created": "2021-06-01T00:00:00",
1068            "song": [
1069                {"id": "101", "title": "So What"},
1070                {"id": "102", "title": "Freddie Freeloader"},
1071                {"id": "103", "title": "Blue in Green"}
1072            ]
1073        }"#;
1074
1075        let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1076
1077        assert_eq!(album.id, "5");
1078        assert_eq!(album.name, "Kind of Blue");
1079        assert_eq!(album.artist.as_deref(), Some("Miles Davis"));
1080        assert_eq!(album.year, Some(1959));
1081        assert_eq!(album.song.len(), 3);
1082        assert_eq!(album.song[0].title, "So What");
1083        assert_eq!(album.song[2].id, "103");
1084    }
1085
1086    #[test]
1087    fn test_deserialize_album_full_empty_song_list() {
1088        // When `song` key is absent, the #[serde(default)] should yield an empty Vec.
1089        let json = r#"{"id": "9", "name": "No Tracks Yet"}"#;
1090
1091        let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1092
1093        assert_eq!(album.id, "9");
1094        assert!(album.song.is_empty(), "song list should default to empty");
1095    }
1096
1097    // --- SubsonicSearchResult deserialization ---
1098
1099    #[test]
1100    fn test_deserialize_search_result_mixed() {
1101        let json = r#"{
1102            "artist": [{"id": "1", "name": "Artist One"}],
1103            "album":  [{"id": "2", "name": "Album One"}],
1104            "song":   [{"id": "3", "title": "Song One"}]
1105        }"#;
1106
1107        let result: SubsonicSearchResult = serde_json::from_str(json).unwrap();
1108
1109        assert_eq!(result.artist.len(), 1);
1110        assert_eq!(result.artist[0].name, "Artist One");
1111        assert_eq!(result.album.len(), 1);
1112        assert_eq!(result.album[0].name, "Album One");
1113        assert_eq!(result.song.len(), 1);
1114        assert_eq!(result.song[0].title, "Song One");
1115    }
1116
1117    #[test]
1118    fn empty_opensubsonic_ids_are_absent() {
1119        let json = r#"{"id": "1", "title": "T", "musicBrainzId": ""}"#;
1120        let song: SubsonicSong = serde_json::from_str(json).unwrap();
1121        assert_eq!(song.music_brainz_id, None);
1122
1123        let json = r#"{"id": "2", "name": "A", "musicBrainzId": "", "sortName": ""}"#;
1124        let album: SubsonicAlbum = serde_json::from_str(json).unwrap();
1125        assert_eq!((album.music_brainz_id, album.sort_name), (None, None));
1126
1127        let json = r#"{"id": "3", "name": "A", "musicBrainzId": "mb-1"}"#;
1128        let album: SubsonicAlbumFull = serde_json::from_str(json).unwrap();
1129        assert_eq!(album.music_brainz_id.as_deref(), Some("mb-1"));
1130        assert_eq!(album.sort_name, None);
1131    }
1132
1133    #[test]
1134    fn test_deserialize_scan_status_count() {
1135        let json = r#"{"subsonic-response":{"status":"ok","scanStatus":{"scanning":false,"count":49700}}}"#;
1136        let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
1137        let status = wrapper.subsonic_response.scan_status.unwrap();
1138        assert_eq!(status.count, Some(49_700));
1139    }
1140
1141    #[test]
1142    fn test_deserialize_search_result_defaults_to_empty() {
1143        // All three lists are #[serde(default)], so an empty object is valid.
1144        let result: SubsonicSearchResult = serde_json::from_str("{}").unwrap();
1145
1146        assert!(result.artist.is_empty());
1147        assert!(result.album.is_empty());
1148        assert!(result.song.is_empty());
1149    }
1150}