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