Skip to main content

koan_core/remote/
client.rs

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