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