Skip to main content

koan_server/
subsonic.rs

1//! Subsonic-compatible REST API layer.
2//!
3//! Implements a subset of the Subsonic/OpenSubsonic REST API backed by the
4//! local koan database.  Supports both XML (default) and JSON (`f=json`)
5//! responses.  Auth uses MD5+salt tokens *and* legacy plaintext passwords.
6
7use std::collections::BTreeMap;
8use std::io::Cursor;
9use std::path::PathBuf;
10use std::sync::Arc;
11use std::time::Duration;
12
13use axum::extract::{Query, State};
14use axum::http::{HeaderMap, StatusCode, header};
15use axum::response::{IntoResponse, Response};
16use axum::routing::get;
17use koan_core::config::Config;
18use koan_core::db::connection::Database;
19use koan_core::db::queries;
20use koan_core::index::metadata::extract_cover_art;
21use serde::Deserialize;
22use tokio::io::AsyncReadExt as _;
23
24const SUBSONIC_API_VERSION: &str = "1.16.1";
25const SUBSONIC_XMLNS: &str = "http://subsonic.org/restapi";
26
27// ---------------------------------------------------------------------------
28// Shared state
29// ---------------------------------------------------------------------------
30
31#[derive(Clone)]
32struct AppState {
33    db_path: PathBuf,
34    username: String,
35    password: String,
36}
37
38impl AppState {
39    fn open_db(&self) -> Result<Database, SubsonicError> {
40        Database::open(&self.db_path).map_err(|e| SubsonicError::from(e.to_string()))
41    }
42}
43
44// ---------------------------------------------------------------------------
45// Subsonic errors
46// ---------------------------------------------------------------------------
47
48#[derive(Debug, Clone, Copy)]
49enum SubsonicErrorCode {
50    Generic = 0,
51    MissingParameter = 10,
52    WrongAuth = 40,
53    NotFound = 70,
54}
55
56#[derive(Debug)]
57struct SubsonicError {
58    code: SubsonicErrorCode,
59    message: String,
60}
61
62impl SubsonicError {
63    fn wrong_auth() -> Self {
64        Self {
65            code: SubsonicErrorCode::WrongAuth,
66            message: "Wrong username or password".into(),
67        }
68    }
69
70    fn missing_param(name: &str) -> Self {
71        Self {
72            code: SubsonicErrorCode::MissingParameter,
73            message: format!("Required parameter '{}' is missing", name),
74        }
75    }
76
77    fn not_found(what: &str) -> Self {
78        Self {
79            code: SubsonicErrorCode::NotFound,
80            message: format!("{} not found", what),
81        }
82    }
83
84    fn internal(msg: impl Into<String>) -> Self {
85        Self {
86            code: SubsonicErrorCode::Generic,
87            message: msg.into(),
88        }
89    }
90}
91
92impl From<String> for SubsonicError {
93    fn from(s: String) -> Self {
94        Self {
95            code: SubsonicErrorCode::Generic,
96            message: s,
97        }
98    }
99}
100
101impl IntoResponse for SubsonicError {
102    fn into_response(self) -> Response {
103        // Default to XML for error responses produced via `?` in handlers.
104        SubsonicResponse::error(false, &self)
105    }
106}
107
108// ---------------------------------------------------------------------------
109// Query params (common to all endpoints)
110// ---------------------------------------------------------------------------
111
112#[derive(Debug, Deserialize)]
113struct SubsonicParams {
114    u: Option<String>,
115    t: Option<String>,
116    s: Option<String>,
117    p: Option<String>,
118    #[allow(dead_code)]
119    v: Option<String>,
120    #[allow(dead_code)]
121    c: Option<String>,
122    f: Option<String>,
123}
124
125impl SubsonicParams {
126    fn wants_json(&self) -> bool {
127        self.f.as_deref() == Some("json")
128    }
129}
130
131// ---------------------------------------------------------------------------
132// Response builder (XML + JSON)
133// ---------------------------------------------------------------------------
134
135struct SubsonicResponse;
136
137impl SubsonicResponse {
138    fn ok(json: bool) -> XmlBuilder {
139        XmlBuilder {
140            json,
141            children: Vec::new(),
142        }
143    }
144
145    fn error(json: bool, err: &SubsonicError) -> Response {
146        if json {
147            let body = serde_json::json!({
148                "subsonic-response": {
149                    "status": "failed",
150                    "version": SUBSONIC_API_VERSION,
151                    "error": {
152                        "code": err.code as i32,
153                        "message": err.message,
154                    }
155                }
156            });
157            (
158                StatusCode::OK,
159                [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
160                serde_json::to_string(&body).unwrap(),
161            )
162                .into_response()
163        } else {
164            let xml = format!(
165                r#"<?xml version="1.0" encoding="UTF-8"?>
166<subsonic-response xmlns="{}" status="failed" version="{}">
167  <error code="{}" message="{}"/>
168</subsonic-response>"#,
169                SUBSONIC_XMLNS,
170                SUBSONIC_API_VERSION,
171                err.code as i32,
172                xml_escape(&err.message),
173            );
174            (
175                StatusCode::OK,
176                [(header::CONTENT_TYPE, "application/xml; charset=utf-8")],
177                xml,
178            )
179                .into_response()
180        }
181    }
182}
183
184// ---------------------------------------------------------------------------
185// Lightweight XML/JSON builder
186// ---------------------------------------------------------------------------
187
188struct XmlBuilder {
189    json: bool,
190    children: Vec<XmlNode>,
191}
192
193#[derive(Clone)]
194struct XmlNode {
195    tag: String,
196    attrs: Vec<(String, String)>,
197    children: Vec<XmlNode>,
198    is_array: bool,
199    array_child_tag: Option<String>,
200}
201
202impl XmlNode {
203    fn new(tag: &str) -> Self {
204        Self {
205            tag: tag.into(),
206            attrs: Vec::new(),
207            children: Vec::new(),
208            is_array: false,
209            array_child_tag: None,
210        }
211    }
212
213    fn attr(mut self, key: &str, value: &str) -> Self {
214        self.attrs.push((key.into(), value.into()));
215        self
216    }
217
218    fn attr_opt(self, key: &str, value: Option<&str>) -> Self {
219        match value {
220            Some(v) => self.attr(key, v),
221            None => self,
222        }
223    }
224
225    fn attr_opt_i32(self, key: &str, value: Option<i32>) -> Self {
226        match value {
227            Some(v) => self.attr(key, &v.to_string()),
228            None => self,
229        }
230    }
231
232    fn attr_opt_i64(self, key: &str, value: Option<i64>) -> Self {
233        match value {
234            Some(v) => self.attr(key, &v.to_string()),
235            None => self,
236        }
237    }
238
239    fn child(mut self, node: XmlNode) -> Self {
240        self.children.push(node);
241        self
242    }
243
244    fn array_of(mut self, child_tag: &str) -> Self {
245        self.is_array = true;
246        self.array_child_tag = Some(child_tag.into());
247        self
248    }
249
250    fn to_xml(&self, indent: usize) -> String {
251        let pad = "  ".repeat(indent);
252        let mut s = format!("<{}", self.tag);
253        for (k, v) in &self.attrs {
254            s.push_str(&format!(" {}=\"{}\"", k, xml_escape(v)));
255        }
256        if self.children.is_empty() {
257            s.push_str("/>");
258            return format!("{}{}", pad, s);
259        }
260        s.push('>');
261        let mut out = format!("{}{}\n", pad, s);
262        for child in &self.children {
263            out.push_str(&child.to_xml(indent + 1));
264            out.push('\n');
265        }
266        out.push_str(&format!("{}</{}>", pad, self.tag));
267        out
268    }
269
270    fn to_json_value(&self) -> serde_json::Value {
271        let mut obj = serde_json::Map::new();
272        for (k, v) in &self.attrs {
273            obj.insert(k.clone(), serde_json::Value::String(v.clone()));
274        }
275        if self.is_array {
276            let child_tag = self.array_child_tag.as_deref().unwrap_or("item");
277            let arr: Vec<serde_json::Value> =
278                self.children.iter().map(|c| c.to_json_value()).collect();
279            obj.insert(child_tag.into(), serde_json::Value::Array(arr));
280        } else {
281            let mut groups: BTreeMap<String, Vec<serde_json::Value>> = BTreeMap::new();
282            for child in &self.children {
283                groups
284                    .entry(child.tag.clone())
285                    .or_default()
286                    .push(child.to_json_value());
287            }
288            for (tag, values) in groups {
289                if values.len() == 1 {
290                    obj.insert(tag, values.into_iter().next().unwrap());
291                } else {
292                    obj.insert(tag, serde_json::Value::Array(values));
293                }
294            }
295        }
296        serde_json::Value::Object(obj)
297    }
298}
299
300impl XmlBuilder {
301    fn child(mut self, node: XmlNode) -> Self {
302        self.children.push(node);
303        self
304    }
305
306    fn build(self) -> Response {
307        if self.json {
308            let mut inner = serde_json::Map::new();
309            inner.insert("status".into(), serde_json::Value::String("ok".into()));
310            inner.insert(
311                "version".into(),
312                serde_json::Value::String(SUBSONIC_API_VERSION.into()),
313            );
314            for child in &self.children {
315                inner.insert(child.tag.clone(), child.to_json_value());
316            }
317            let wrapper = serde_json::json!({ "subsonic-response": inner });
318            (
319                StatusCode::OK,
320                [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
321                serde_json::to_string(&wrapper).unwrap(),
322            )
323                .into_response()
324        } else {
325            let mut xml = format!(
326                "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<subsonic-response xmlns=\"{}\" status=\"ok\" version=\"{}\">\n",
327                SUBSONIC_XMLNS, SUBSONIC_API_VERSION,
328            );
329            for child in &self.children {
330                xml.push_str(&child.to_xml(1));
331                xml.push('\n');
332            }
333            xml.push_str("</subsonic-response>");
334            (
335                StatusCode::OK,
336                [(header::CONTENT_TYPE, "application/xml; charset=utf-8")],
337                xml,
338            )
339                .into_response()
340        }
341    }
342}
343
344fn xml_escape(s: &str) -> String {
345    s.replace('&', "&amp;")
346        .replace('"', "&quot;")
347        .replace('<', "&lt;")
348        .replace('>', "&gt;")
349        .replace('\'', "&apos;")
350}
351
352// ---------------------------------------------------------------------------
353// Auth — MD5+salt token *and* legacy plaintext password
354// ---------------------------------------------------------------------------
355
356fn validate_auth(params: &SubsonicParams, state: &AppState) -> Result<(), SubsonicError> {
357    let username = params
358        .u
359        .as_deref()
360        .ok_or_else(|| SubsonicError::missing_param("u"))?;
361
362    if username != state.username {
363        return Err(SubsonicError::wrong_auth());
364    }
365
366    // Token-based: t = md5(password + s)
367    if let (Some(token), Some(salt)) = (params.t.as_deref(), params.s.as_deref()) {
368        let expected = format!("{:x}", md5::compute(format!("{}{}", state.password, salt)));
369        use subtle::ConstantTimeEq;
370        if token.as_bytes().ct_eq(expected.as_bytes()).into() {
371            return Ok(());
372        }
373        return Err(SubsonicError::wrong_auth());
374    }
375
376    // Legacy plaintext (with optional enc: hex prefix)
377    if let Some(ref p) = params.p {
378        let plain = if let Some(hex) = p.strip_prefix("enc:") {
379            hex_decode(hex)
380        } else {
381            p.clone()
382        };
383        use subtle::ConstantTimeEq;
384        if plain.as_bytes().ct_eq(state.password.as_bytes()).into() {
385            return Ok(());
386        }
387        return Err(SubsonicError::wrong_auth());
388    }
389
390    Err(SubsonicError::missing_param("t and s, or p"))
391}
392
393fn hex_decode(hex: &str) -> String {
394    let bytes: Vec<u8> = (0..hex.len())
395        .step_by(2)
396        .filter_map(|i| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok())
397        .collect();
398    String::from_utf8_lossy(&bytes).into_owned()
399}
400
401// ---------------------------------------------------------------------------
402// Helpers: track/album → XmlNode
403// ---------------------------------------------------------------------------
404
405fn track_to_xml_node(track: &queries::TrackRow) -> XmlNode {
406    let duration_secs = track.duration_ms.map(|ms| ms / 1000);
407    let (suffix, content_type) = track
408        .codec
409        .as_deref()
410        .map(codec_to_mime)
411        .unwrap_or(("bin", "application/octet-stream"));
412    XmlNode::new("song")
413        .attr("id", &track.id.to_string())
414        .attr("title", &track.title)
415        .attr("album", &track.album_title)
416        .attr("artist", &track.artist_name)
417        .attr_opt_i32("track", track.track_number)
418        .attr_opt_i32("discNumber", track.disc)
419        .attr_opt_i64("duration", duration_secs)
420        .attr_opt_i32("bitRate", track.bitrate)
421        .attr_opt("suffix", Some(suffix))
422        .attr_opt("contentType", Some(content_type))
423        .attr_opt("genre", track.genre.as_deref())
424        .attr_opt(
425            "albumId",
426            track.album_id.map(|id| id.to_string()).as_deref(),
427        )
428        .attr_opt(
429            "artistId",
430            track.artist_id.map(|id| id.to_string()).as_deref(),
431        )
432}
433
434fn year_from_date(date: Option<&str>) -> Option<String> {
435    date.and_then(|d| {
436        if d.len() >= 4 {
437            Some(d[..4].to_string())
438        } else {
439            None
440        }
441    })
442}
443
444fn album_to_xml_node(album: &queries::AlbumRow, track_count: Option<i32>) -> XmlNode {
445    let year_str = year_from_date(album.date.as_deref());
446    let count = track_count.unwrap_or(0);
447    XmlNode::new("album")
448        .attr("id", &album.id.to_string())
449        .attr("name", &album.title)
450        .attr("artist", &album.artist_name)
451        .attr("artistId", &album.artist_id.to_string())
452        .attr("songCount", &count.to_string())
453        .attr_opt("year", year_str.as_deref())
454}
455
456fn album_counts_by_artist(db: &Database) -> BTreeMap<i64, i64> {
457    let albums = queries::all_albums(&db.conn).unwrap_or_default();
458    let mut map: BTreeMap<i64, i64> = BTreeMap::new();
459    for album in albums {
460        *map.entry(album.artist_id).or_insert(0) += 1;
461    }
462    map
463}
464
465fn codec_to_mime(codec: &str) -> (&str, &str) {
466    match codec.to_uppercase().as_str() {
467        "FLAC" => ("flac", "audio/flac"),
468        "MP3" => ("mp3", "audio/mpeg"),
469        "AAC" | "M4A" => ("m4a", "audio/mp4"),
470        "OPUS" => ("opus", "audio/opus"),
471        "VORBIS" | "OGG" => ("ogg", "audio/ogg"),
472        "WAV" => ("wav", "audio/wav"),
473        "AIFF" => ("aiff", "audio/aiff"),
474        "WAVPACK" | "WV" => ("wv", "audio/x-wavpack"),
475        "APE" => ("ape", "audio/x-ape"),
476        _ => ("bin", "application/octet-stream"),
477    }
478}
479
480fn extension_to_mime(ext: &str) -> &str {
481    match ext.to_lowercase().as_str() {
482        "flac" => "audio/flac",
483        "mp3" => "audio/mpeg",
484        "m4a" | "aac" | "mp4" => "audio/mp4",
485        "opus" => "audio/opus",
486        "ogg" => "audio/ogg",
487        "wav" => "audio/wav",
488        "aiff" | "aif" => "audio/aiff",
489        "wv" => "audio/x-wavpack",
490        "ape" => "audio/x-ape",
491        _ => "application/octet-stream",
492    }
493}
494
495/// Resolve a track's file path (local preferred, then cached).
496fn track_file_path(track: &queries::TrackRow) -> Option<&str> {
497    track.path.as_deref().or(track.cached_path.as_deref())
498}
499
500// ---------------------------------------------------------------------------
501// Endpoint param structs
502// ---------------------------------------------------------------------------
503
504#[derive(Debug, Deserialize)]
505struct IdParam {
506    id: Option<String>,
507    #[serde(flatten)]
508    auth: SubsonicParams,
509}
510
511#[derive(Debug, Deserialize)]
512#[serde(rename_all = "camelCase")]
513struct AlbumListParams {
514    #[serde(rename = "type")]
515    list_type: Option<String>,
516    size: Option<i64>,
517    offset: Option<i64>,
518    #[serde(flatten)]
519    auth: SubsonicParams,
520}
521
522#[derive(Debug, Deserialize)]
523#[serde(rename_all = "camelCase")]
524struct Search3Params {
525    #[serde(flatten)]
526    auth: SubsonicParams,
527    query: Option<String>,
528    artist_count: Option<u32>,
529    album_count: Option<u32>,
530    song_count: Option<u32>,
531}
532
533#[derive(Debug, Deserialize)]
534struct StreamParams {
535    #[serde(flatten)]
536    auth: SubsonicParams,
537    id: Option<i64>,
538}
539
540#[derive(Debug, Deserialize)]
541struct CoverArtParams {
542    #[serde(flatten)]
543    auth: SubsonicParams,
544    id: Option<i64>,
545    size: Option<u32>,
546}
547
548#[derive(Debug, Deserialize)]
549#[serde(rename_all = "camelCase")]
550struct StarParams {
551    #[serde(flatten)]
552    auth: SubsonicParams,
553    id: Option<i64>,
554}
555
556#[derive(Debug, Deserialize)]
557#[serde(rename_all = "camelCase")]
558struct ScrobbleParams {
559    #[serde(flatten)]
560    auth: SubsonicParams,
561    id: Option<i64>,
562    time: Option<i64>,
563}
564
565#[derive(Debug, Deserialize)]
566#[serde(rename_all = "camelCase")]
567struct RandomSongsParams {
568    #[serde(flatten)]
569    auth: SubsonicParams,
570    size: Option<u32>,
571    genre: Option<String>,
572}
573
574#[derive(Debug, Deserialize)]
575#[serde(rename_all = "camelCase")]
576struct SimilarSongs2Params {
577    #[serde(flatten)]
578    auth: SubsonicParams,
579    id: Option<i64>,
580    count: Option<usize>,
581}
582
583// ===========================================================================
584// Endpoints — browsing
585// ===========================================================================
586
587async fn ping(
588    State(state): State<Arc<AppState>>,
589    Query(params): Query<SubsonicParams>,
590) -> Response {
591    if let Err(e) = validate_auth(&params, &state) {
592        return SubsonicResponse::error(params.wants_json(), &e);
593    }
594    SubsonicResponse::ok(params.wants_json()).build()
595}
596
597async fn get_license(
598    State(state): State<Arc<AppState>>,
599    Query(params): Query<SubsonicParams>,
600) -> Response {
601    if let Err(e) = validate_auth(&params, &state) {
602        return SubsonicResponse::error(params.wants_json(), &e);
603    }
604    SubsonicResponse::ok(params.wants_json())
605        .child(
606            XmlNode::new("license")
607                .attr("valid", "true")
608                .attr("email", "koan@localhost"),
609        )
610        .build()
611}
612
613async fn get_artists(
614    State(state): State<Arc<AppState>>,
615    Query(params): Query<SubsonicParams>,
616) -> Response {
617    if let Err(e) = validate_auth(&params, &state) {
618        return SubsonicResponse::error(params.wants_json(), &e);
619    }
620    let json = params.wants_json();
621
622    let db = match state.open_db() {
623        Ok(db) => db,
624        Err(e) => return SubsonicResponse::error(json, &e),
625    };
626
627    let artists = match queries::all_artists(&db.conn) {
628        Ok(a) => a,
629        Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
630    };
631
632    // Group by first letter.
633    let mut index_map: BTreeMap<String, Vec<&queries::ArtistRow>> = BTreeMap::new();
634    for artist in &artists {
635        let letter = artist
636            .sort_name
637            .as_deref()
638            .unwrap_or(&artist.name)
639            .chars()
640            .next()
641            .map(|c| {
642                let upper = c.to_uppercase().to_string();
643                if upper
644                    .chars()
645                    .next()
646                    .is_some_and(|ch| ch.is_ascii_alphabetic())
647                {
648                    upper
649                } else {
650                    "#".to_string()
651                }
652            })
653            .unwrap_or_else(|| "#".to_string());
654        index_map.entry(letter).or_default().push(artist);
655    }
656
657    let album_counts = album_counts_by_artist(&db);
658
659    let mut artists_node = XmlNode::new("artists").array_of("index");
660    for (letter, group) in &index_map {
661        let mut index_node = XmlNode::new("index")
662            .attr("name", letter)
663            .array_of("artist");
664        for artist in group {
665            let count = album_counts.get(&artist.id).copied().unwrap_or(0);
666            index_node = index_node.child(
667                XmlNode::new("artist")
668                    .attr("id", &artist.id.to_string())
669                    .attr("name", &artist.name)
670                    .attr("albumCount", &count.to_string()),
671            );
672        }
673        artists_node = artists_node.child(index_node);
674    }
675
676    SubsonicResponse::ok(json).child(artists_node).build()
677}
678
679async fn get_artist(State(state): State<Arc<AppState>>, Query(params): Query<IdParam>) -> Response {
680    if let Err(e) = validate_auth(&params.auth, &state) {
681        return SubsonicResponse::error(params.auth.wants_json(), &e);
682    }
683    let json = params.auth.wants_json();
684
685    let artist_id: i64 = match params.id.as_deref().and_then(|s| s.parse().ok()) {
686        Some(id) => id,
687        None => return SubsonicResponse::error(json, &SubsonicError::missing_param("id")),
688    };
689
690    let db = match state.open_db() {
691        Ok(db) => db,
692        Err(e) => return SubsonicResponse::error(json, &e),
693    };
694
695    let all = match queries::all_artists(&db.conn) {
696        Ok(a) => a,
697        Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
698    };
699    let artist = match all.into_iter().find(|a| a.id == artist_id) {
700        Some(a) => a,
701        None => return SubsonicResponse::error(json, &SubsonicError::not_found("Artist")),
702    };
703
704    let albums = match queries::albums_for_artist(&db.conn, artist_id) {
705        Ok(a) => a,
706        Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
707    };
708
709    let mut artist_node = XmlNode::new("artist")
710        .attr("id", &artist.id.to_string())
711        .attr("name", &artist.name)
712        .attr("albumCount", &albums.len().to_string())
713        .array_of("album");
714
715    for album in &albums {
716        artist_node = artist_node.child(album_to_xml_node(album, album.total_tracks));
717    }
718
719    SubsonicResponse::ok(json).child(artist_node).build()
720}
721
722async fn get_album(State(state): State<Arc<AppState>>, Query(params): Query<IdParam>) -> Response {
723    if let Err(e) = validate_auth(&params.auth, &state) {
724        return SubsonicResponse::error(params.auth.wants_json(), &e);
725    }
726    let json = params.auth.wants_json();
727
728    let album_id: i64 = match params.id.as_deref().and_then(|s| s.parse().ok()) {
729        Some(id) => id,
730        None => return SubsonicResponse::error(json, &SubsonicError::missing_param("id")),
731    };
732
733    let db = match state.open_db() {
734        Ok(db) => db,
735        Err(e) => return SubsonicResponse::error(json, &e),
736    };
737
738    let album = match queries::get_album(&db.conn, album_id) {
739        Ok(Some(a)) => a,
740        _ => return SubsonicResponse::error(json, &SubsonicError::not_found("Album")),
741    };
742
743    let tracks = queries::tracks_for_album(&db.conn, album_id).unwrap_or_default();
744    let mut album_node = album_to_xml_node(&album, Some(tracks.len() as i32)).array_of("song");
745
746    for track in &tracks {
747        album_node = album_node.child(track_to_xml_node(track));
748    }
749
750    SubsonicResponse::ok(json).child(album_node).build()
751}
752
753async fn get_album_list2(
754    State(state): State<Arc<AppState>>,
755    Query(params): Query<AlbumListParams>,
756) -> Response {
757    if let Err(e) = validate_auth(&params.auth, &state) {
758        return SubsonicResponse::error(params.auth.wants_json(), &e);
759    }
760    let json = params.auth.wants_json();
761
762    let list_type = params.list_type.as_deref().unwrap_or("alphabeticalByName");
763    let size = params.size.unwrap_or(20).min(500) as usize;
764    let offset = params.offset.unwrap_or(0).max(0) as usize;
765
766    let db = match state.open_db() {
767        Ok(db) => db,
768        Err(e) => return SubsonicResponse::error(json, &e),
769    };
770
771    let mut albums = match queries::all_albums(&db.conn) {
772        Ok(a) => a,
773        Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
774    };
775
776    match list_type {
777        "alphabeticalByName" => albums.sort_by(|a, b| a.title.cmp(&b.title)),
778        "alphabeticalByArtist" => albums.sort_by(|a, b| {
779            a.artist_name
780                .cmp(&b.artist_name)
781                .then(a.title.cmp(&b.title))
782        }),
783        "newest" => albums.sort_by(|a, b| b.date.cmp(&a.date)),
784        "random" => {
785            use std::collections::hash_map::DefaultHasher;
786            use std::hash::{Hash, Hasher};
787            let seed = std::time::SystemTime::now()
788                .duration_since(std::time::UNIX_EPOCH)
789                .unwrap_or_default()
790                .as_secs();
791            albums.sort_by(|a, b| {
792                let mut ha = DefaultHasher::new();
793                (a.id, seed).hash(&mut ha);
794                let mut hb = DefaultHasher::new();
795                (b.id, seed).hash(&mut hb);
796                ha.finish().cmp(&hb.finish())
797            });
798        }
799        _ => {}
800    }
801
802    let page: Vec<_> = albums.into_iter().skip(offset).take(size).collect();
803
804    let mut list_node = XmlNode::new("albumList2").array_of("album");
805    for album in &page {
806        list_node = list_node.child(album_to_xml_node(album, album.total_tracks));
807    }
808
809    SubsonicResponse::ok(json).child(list_node).build()
810}
811
812async fn get_song(State(state): State<Arc<AppState>>, Query(params): Query<IdParam>) -> Response {
813    if let Err(e) = validate_auth(&params.auth, &state) {
814        return SubsonicResponse::error(params.auth.wants_json(), &e);
815    }
816    let json = params.auth.wants_json();
817
818    let track_id: i64 = match params.id.as_deref().and_then(|s| s.parse().ok()) {
819        Some(id) => id,
820        None => return SubsonicResponse::error(json, &SubsonicError::missing_param("id")),
821    };
822
823    let db = match state.open_db() {
824        Ok(db) => db,
825        Err(e) => return SubsonicResponse::error(json, &e),
826    };
827
828    let track = match queries::get_track_row(&db.conn, track_id) {
829        Ok(Some(t)) => t,
830        _ => return SubsonicResponse::error(json, &SubsonicError::not_found("Song")),
831    };
832
833    SubsonicResponse::ok(json)
834        .child(track_to_xml_node(&track))
835        .build()
836}
837
838// ===========================================================================
839// Endpoints — search
840// ===========================================================================
841
842async fn search3(
843    State(state): State<Arc<AppState>>,
844    Query(params): Query<Search3Params>,
845) -> Response {
846    if let Err(e) = validate_auth(&params.auth, &state) {
847        return SubsonicResponse::error(params.auth.wants_json(), &e);
848    }
849    let json = params.auth.wants_json();
850
851    let query = match params.query.as_deref() {
852        Some(q) => q,
853        None => return SubsonicResponse::error(json, &SubsonicError::missing_param("query")),
854    };
855
856    let artist_count = params.artist_count.unwrap_or(20);
857    let album_count = params.album_count.unwrap_or(20);
858    let song_count = params.song_count.unwrap_or(20);
859
860    let db = match state.open_db() {
861        Ok(db) => db,
862        Err(e) => return SubsonicResponse::error(json, &e),
863    };
864
865    let total_needed = (artist_count + album_count + song_count).max(100);
866    let tracks = match queries::search_tracks_paged(&db.conn, query, total_needed, 0) {
867        Ok(t) => t,
868        Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
869    };
870
871    let mut result_node = XmlNode::new("searchResult3");
872
873    // Unique artists.
874    let mut seen_artists = std::collections::HashSet::new();
875    let mut artist_n = 0u32;
876    for t in &tracks {
877        if artist_n >= artist_count {
878            break;
879        }
880        if let Some(aid) = t.artist_id
881            && seen_artists.insert(aid)
882        {
883            result_node = result_node.child(
884                XmlNode::new("artist")
885                    .attr("id", &aid.to_string())
886                    .attr("name", &t.artist_name),
887            );
888            artist_n += 1;
889        }
890    }
891
892    // Unique albums.
893    let mut seen_albums = std::collections::HashSet::new();
894    let mut album_n = 0u32;
895    for t in &tracks {
896        if album_n >= album_count {
897            break;
898        }
899        if let Some(alid) = t.album_id
900            && seen_albums.insert(alid)
901        {
902            result_node = result_node.child(
903                XmlNode::new("album")
904                    .attr("id", &alid.to_string())
905                    .attr("name", &t.album_title)
906                    .attr("artist", &t.album_artist_name),
907            );
908            album_n += 1;
909        }
910    }
911
912    // Songs.
913    for t in tracks.iter().take(song_count as usize) {
914        result_node = result_node.child(track_to_xml_node(t));
915    }
916
917    SubsonicResponse::ok(json).child(result_node).build()
918}
919
920// ===========================================================================
921// Endpoints — streaming
922// ===========================================================================
923
924async fn stream(
925    State(state): State<Arc<AppState>>,
926    Query(params): Query<StreamParams>,
927    headers: HeaderMap,
928) -> Result<Response, SubsonicError> {
929    validate_auth(&params.auth, &state)?;
930
931    let track_id = params
932        .id
933        .ok_or_else(|| SubsonicError::missing_param("id"))?;
934
935    let db = state.open_db()?;
936    let track = queries::get_track_row(&db.conn, track_id)
937        .map_err(|e| SubsonicError::internal(e.to_string()))?
938        .ok_or_else(|| SubsonicError::not_found("Track"))?;
939
940    // Try local/cached file first; fall back to proxying from upstream.
941    let local_path = track_file_path(&track).map(PathBuf::from);
942    let local_exists = if let Some(ref p) = local_path {
943        tokio::fs::metadata(p).await.is_ok()
944    } else {
945        false
946    };
947
948    if !local_exists {
949        // Proxy from upstream Navidrome/Subsonic server.
950        if let Some(ref remote_id) = track.remote_id {
951            return proxy_stream_from_upstream(remote_id, &track, &headers).await;
952        }
953        return Err(SubsonicError::not_found(
954            "Track has no local file and no remote source",
955        ));
956    }
957
958    let path = local_path.unwrap();
959    let metadata = tokio::fs::metadata(&path).await.map_err(|e| {
960        if e.kind() == std::io::ErrorKind::NotFound {
961            SubsonicError::not_found("File not found on disk")
962        } else {
963            SubsonicError::internal(e.to_string())
964        }
965    })?;
966    let total_size = metadata.len();
967
968    let content_type = path
969        .extension()
970        .and_then(|e| e.to_str())
971        .map(extension_to_mime)
972        .unwrap_or("application/octet-stream");
973
974    // Parse Range header for seeking support.
975    if let Some(range_header) = headers.get(header::RANGE) {
976        let range_str = range_header
977            .to_str()
978            .map_err(|_| SubsonicError::internal("invalid range header"))?;
979
980        if let Some((start, end)) = parse_range(range_str, total_size) {
981            let length = end - start + 1;
982
983            let mut file = tokio::fs::File::open(&path)
984                .await
985                .map_err(|e| SubsonicError::internal(e.to_string()))?;
986            tokio::io::AsyncSeekExt::seek(&mut file, std::io::SeekFrom::Start(start))
987                .await
988                .map_err(|e| SubsonicError::internal(e.to_string()))?;
989
990            let stream = tokio_util::io::ReaderStream::new(file.take(length));
991            let body = axum::body::Body::from_stream(stream);
992
993            return Response::builder()
994                .status(StatusCode::PARTIAL_CONTENT)
995                .header(header::CONTENT_TYPE, content_type)
996                .header(header::CONTENT_LENGTH, length)
997                .header(
998                    header::CONTENT_RANGE,
999                    format!("bytes {}-{}/{}", start, end, total_size),
1000                )
1001                .header(header::ACCEPT_RANGES, "bytes")
1002                .body(body)
1003                .map_err(|e| SubsonicError::internal(e.to_string()));
1004        }
1005    }
1006
1007    // No range — serve full file.
1008    let file = tokio::fs::File::open(&path)
1009        .await
1010        .map_err(|e| SubsonicError::internal(e.to_string()))?;
1011    let stream = tokio_util::io::ReaderStream::new(file);
1012    let body = axum::body::Body::from_stream(stream);
1013
1014    Response::builder()
1015        .status(StatusCode::OK)
1016        .header(header::CONTENT_TYPE, content_type)
1017        .header(header::CONTENT_LENGTH, total_size)
1018        .header(header::ACCEPT_RANGES, "bytes")
1019        .body(body)
1020        .map_err(|e| SubsonicError::internal(e.to_string()))
1021}
1022
1023/// Proxy a stream from the upstream Navidrome/Subsonic server.
1024/// Forwards the audio bytes through to the client, passing along Range headers.
1025async fn proxy_stream_from_upstream(
1026    remote_id: &str,
1027    track: &queries::TrackRow,
1028    client_headers: &HeaderMap,
1029) -> Result<Response, SubsonicError> {
1030    let cfg = Config::load().unwrap_or_default();
1031    let client = match koan_core::helpers::subsonic_client(&cfg) {
1032        Some(c) => c,
1033        None => return Err(SubsonicError::not_found("Remote server not configured")),
1034    };
1035    let upstream_url = client.stream_url(remote_id);
1036
1037    // Use reqwest async to proxy the stream.
1038    let http = reqwest::Client::builder()
1039        .timeout(Duration::from_secs(30))
1040        .build()
1041        .unwrap();
1042    let mut req = http.get(&upstream_url);
1043
1044    // Forward Range header if present.
1045    if let Some(range) = client_headers.get(header::RANGE)
1046        && let Ok(range_str) = range.to_str()
1047    {
1048        req = req.header("Range", range_str);
1049    }
1050
1051    let upstream_resp = req
1052        .send()
1053        .await
1054        .map_err(|e| SubsonicError::internal(format!("upstream error: {}", e)))?;
1055
1056    let status = upstream_resp.status();
1057    let content_type = track
1058        .codec
1059        .as_deref()
1060        .map(|c| codec_to_mime(c).1)
1061        .unwrap_or("application/octet-stream");
1062
1063    let mut builder = Response::builder().status(status.as_u16());
1064    builder = builder.header(header::CONTENT_TYPE, content_type);
1065
1066    // Forward content-length and range headers from upstream.
1067    if let Some(cl) = upstream_resp.headers().get(header::CONTENT_LENGTH) {
1068        builder = builder.header(header::CONTENT_LENGTH, cl);
1069    }
1070    if let Some(cr) = upstream_resp.headers().get(header::CONTENT_RANGE) {
1071        builder = builder.header(header::CONTENT_RANGE, cr);
1072    }
1073    builder = builder.header(header::ACCEPT_RANGES, "bytes");
1074
1075    let body = axum::body::Body::from_stream(upstream_resp.bytes_stream());
1076    builder
1077        .body(body)
1078        .map_err(|e| SubsonicError::internal(e.to_string()))
1079}
1080
1081fn parse_range(range: &str, total: u64) -> Option<(u64, u64)> {
1082    let bytes_prefix = range.strip_prefix("bytes=")?;
1083    let mut parts = bytes_prefix.splitn(2, '-');
1084    let start_str = parts.next()?.trim();
1085    let end_str = parts.next()?.trim();
1086
1087    if start_str.is_empty() {
1088        let suffix: u64 = end_str.parse().ok()?;
1089        let start = total.saturating_sub(suffix);
1090        Some((start, total - 1))
1091    } else {
1092        let start: u64 = start_str.parse().ok()?;
1093        let end = if end_str.is_empty() {
1094            total - 1
1095        } else {
1096            end_str.parse::<u64>().ok()?.min(total - 1)
1097        };
1098        if start > end || start >= total {
1099            return None;
1100        }
1101        Some((start, end))
1102    }
1103}
1104
1105// ===========================================================================
1106// Endpoints — cover art
1107// ===========================================================================
1108
1109async fn get_cover_art(
1110    State(state): State<Arc<AppState>>,
1111    Query(params): Query<CoverArtParams>,
1112) -> Result<Response, SubsonicError> {
1113    validate_auth(&params.auth, &state)?;
1114
1115    let track_id = params
1116        .id
1117        .ok_or_else(|| SubsonicError::missing_param("id"))?;
1118
1119    let db = state.open_db()?;
1120    let track = queries::get_track_row(&db.conn, track_id)
1121        .map_err(|e| SubsonicError::internal(e.to_string()))?
1122        .ok_or_else(|| SubsonicError::not_found("Track"))?;
1123
1124    let file_path = track_file_path(&track)
1125        .ok_or_else(|| SubsonicError::not_found("Track has no local file"))?;
1126
1127    let path = PathBuf::from(file_path);
1128    let art_bytes = extract_cover_art(&path)
1129        .ok_or_else(|| SubsonicError::not_found("No cover art embedded"))?;
1130
1131    let (content_type, is_png) = if art_bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
1132        ("image/png", true)
1133    } else {
1134        ("image/jpeg", false)
1135    };
1136
1137    let final_bytes = if let Some(size) = params.size {
1138        resize_image(&art_bytes, size, is_png)?
1139    } else {
1140        art_bytes
1141    };
1142
1143    Ok((
1144        StatusCode::OK,
1145        [
1146            (header::CONTENT_TYPE, content_type),
1147            (header::CACHE_CONTROL, "max-age=86400"),
1148        ],
1149        final_bytes,
1150    )
1151        .into_response())
1152}
1153
1154fn resize_image(data: &[u8], size: u32, output_png: bool) -> Result<Vec<u8>, SubsonicError> {
1155    let img = image::load_from_memory(data)
1156        .map_err(|e| SubsonicError::internal(format!("image decode error: {}", e)))?;
1157    let resized = img.resize(size, size, image::imageops::FilterType::Lanczos3);
1158    let format = if output_png {
1159        image::ImageFormat::Png
1160    } else {
1161        image::ImageFormat::Jpeg
1162    };
1163    let mut buf = Cursor::new(Vec::new());
1164    resized
1165        .write_to(&mut buf, format)
1166        .map_err(|e| SubsonicError::internal(format!("image encode error: {}", e)))?;
1167    Ok(buf.into_inner())
1168}
1169
1170// ===========================================================================
1171// Endpoints — interaction (star, unstar, scrobble, etc.)
1172// ===========================================================================
1173
1174async fn star(State(state): State<Arc<AppState>>, Query(params): Query<StarParams>) -> Response {
1175    if let Err(e) = validate_auth(&params.auth, &state) {
1176        return SubsonicResponse::error(params.auth.wants_json(), &e);
1177    }
1178    let json = params.auth.wants_json();
1179    toggle_star(&state, params.id, json, queries::add_favourite)
1180}
1181
1182async fn unstar(State(state): State<Arc<AppState>>, Query(params): Query<StarParams>) -> Response {
1183    if let Err(e) = validate_auth(&params.auth, &state) {
1184        return SubsonicResponse::error(params.auth.wants_json(), &e);
1185    }
1186    let json = params.auth.wants_json();
1187    toggle_star(&state, params.id, json, queries::remove_favourite)
1188}
1189
1190fn toggle_star(
1191    state: &AppState,
1192    id: Option<i64>,
1193    json: bool,
1194    op: fn(&rusqlite::Connection, &std::path::Path) -> rusqlite::Result<()>,
1195) -> Response {
1196    let track_id = match id {
1197        Some(id) => id,
1198        None => return SubsonicResponse::error(json, &SubsonicError::missing_param("id")),
1199    };
1200
1201    let db = match state.open_db() {
1202        Ok(db) => db,
1203        Err(e) => return SubsonicResponse::error(json, &e),
1204    };
1205
1206    let track = match queries::get_track_row(&db.conn, track_id) {
1207        Ok(Some(t)) => t,
1208        Ok(None) => return SubsonicResponse::error(json, &SubsonicError::not_found("Track")),
1209        Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
1210    };
1211
1212    let path_str = track_file_path(&track).unwrap_or("");
1213    if let Err(e) = op(&db.conn, std::path::Path::new(path_str)) {
1214        return SubsonicResponse::error(json, &SubsonicError::from(e.to_string()));
1215    }
1216
1217    SubsonicResponse::ok(json).build()
1218}
1219
1220async fn get_starred2(
1221    State(state): State<Arc<AppState>>,
1222    Query(params): Query<SubsonicParams>,
1223) -> Response {
1224    if let Err(e) = validate_auth(&params, &state) {
1225        return SubsonicResponse::error(params.wants_json(), &e);
1226    }
1227    let json = params.wants_json();
1228
1229    let db = match state.open_db() {
1230        Ok(db) => db,
1231        Err(e) => return SubsonicResponse::error(json, &e),
1232    };
1233
1234    let favourites = match queries::load_favourites(&db.conn) {
1235        Ok(f) => f,
1236        Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
1237    };
1238
1239    let mut starred_node = XmlNode::new("starred2").array_of("song");
1240    for fav_path in &favourites {
1241        let path_str = fav_path.to_string_lossy();
1242        if let Ok(Some(track_id)) = queries::track_id_by_path(&db.conn, &path_str)
1243            && let Ok(Some(track)) = queries::get_track_row(&db.conn, track_id)
1244        {
1245            starred_node = starred_node.child(track_to_xml_node(&track));
1246        }
1247    }
1248
1249    SubsonicResponse::ok(json).child(starred_node).build()
1250}
1251
1252async fn scrobble(
1253    State(state): State<Arc<AppState>>,
1254    Query(params): Query<ScrobbleParams>,
1255) -> Response {
1256    if let Err(e) = validate_auth(&params.auth, &state) {
1257        return SubsonicResponse::error(params.auth.wants_json(), &e);
1258    }
1259    let json = params.auth.wants_json();
1260
1261    let track_id = match params.id {
1262        Some(id) => id,
1263        None => return SubsonicResponse::error(json, &SubsonicError::missing_param("id")),
1264    };
1265
1266    let db = match state.open_db() {
1267        Ok(db) => db,
1268        Err(e) => return SubsonicResponse::error(json, &e),
1269    };
1270
1271    match queries::get_track_row(&db.conn, track_id) {
1272        Ok(Some(_)) => {}
1273        Ok(None) => return SubsonicResponse::error(json, &SubsonicError::not_found("Track")),
1274        Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
1275    }
1276
1277    let result = if let Some(time_ms) = params.time {
1278        db.conn
1279            .execute(
1280                "INSERT INTO play_history (track_id, played_at, duration_ms) VALUES (?1, ?2, NULL)",
1281                rusqlite::params![track_id, time_ms / 1000],
1282            )
1283            .map(|_| ())
1284            .map_err(|e| e.into())
1285    } else {
1286        queries::record_play(&db.conn, track_id, None)
1287    };
1288
1289    if let Err(e) = result {
1290        return SubsonicResponse::error(
1291            json,
1292            &SubsonicError::from(format!("Database error: {}", e)),
1293        );
1294    }
1295
1296    SubsonicResponse::ok(json).build()
1297}
1298
1299async fn get_random_songs(
1300    State(state): State<Arc<AppState>>,
1301    Query(params): Query<RandomSongsParams>,
1302) -> Response {
1303    if let Err(e) = validate_auth(&params.auth, &state) {
1304        return SubsonicResponse::error(params.auth.wants_json(), &e);
1305    }
1306    let json = params.auth.wants_json();
1307
1308    let size = params.size.unwrap_or(10);
1309    let genre = params.genre.as_deref();
1310    let fetch_count = if genre.is_some() { size * 5 } else { size };
1311
1312    let db = match state.open_db() {
1313        Ok(db) => db,
1314        Err(e) => return SubsonicResponse::error(json, &e),
1315    };
1316
1317    let tracks = match queries::random_tracks(&db.conn, fetch_count, None) {
1318        Ok(t) => t,
1319        Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
1320    };
1321
1322    let mut node = XmlNode::new("randomSongs").array_of("song");
1323    let mut count = 0u32;
1324    for t in &tracks {
1325        if count >= size {
1326            break;
1327        }
1328        if genre.is_some_and(|g| t.genre.as_deref() != Some(g)) {
1329            continue;
1330        }
1331        node = node.child(track_to_xml_node(t));
1332        count += 1;
1333    }
1334
1335    SubsonicResponse::ok(json).child(node).build()
1336}
1337
1338async fn get_similar_songs2(
1339    State(state): State<Arc<AppState>>,
1340    Query(params): Query<SimilarSongs2Params>,
1341) -> Response {
1342    if let Err(e) = validate_auth(&params.auth, &state) {
1343        return SubsonicResponse::error(params.auth.wants_json(), &e);
1344    }
1345    let json = params.auth.wants_json();
1346
1347    let track_id = match params.id {
1348        Some(id) => id,
1349        None => return SubsonicResponse::error(json, &SubsonicError::missing_param("id")),
1350    };
1351    let count = params.count.unwrap_or(50);
1352
1353    let db = match state.open_db() {
1354        Ok(db) => db,
1355        Err(e) => return SubsonicResponse::error(json, &e),
1356    };
1357
1358    let track = match queries::get_track_row(&db.conn, track_id) {
1359        Ok(Some(t)) => t,
1360        Ok(None) => return SubsonicResponse::error(json, &SubsonicError::not_found("Track")),
1361        Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
1362    };
1363
1364    let artist_id = match track.artist_id {
1365        Some(id) => id,
1366        None => {
1367            return SubsonicResponse::ok(json)
1368                .child(XmlNode::new("similarSongs2"))
1369                .build();
1370        }
1371    };
1372
1373    let similar = match queries::get_similar_artists(&db.conn, artist_id) {
1374        Ok(s) => s,
1375        Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
1376    };
1377
1378    let mut node = XmlNode::new("similarSongs2").array_of("song");
1379    let mut total = 0usize;
1380    for (artist_row, _score) in &similar {
1381        if total >= count {
1382            break;
1383        }
1384        let artist_tracks = match queries::tracks_for_artist(&db.conn, artist_row.id) {
1385            Ok(t) => t,
1386            Err(_) => continue,
1387        };
1388        for t in &artist_tracks {
1389            if total >= count {
1390                break;
1391            }
1392            node = node.child(track_to_xml_node(t));
1393            total += 1;
1394        }
1395    }
1396
1397    SubsonicResponse::ok(json).child(node).build()
1398}
1399
1400// ---------------------------------------------------------------------------
1401// Endpoint: getMusicFolders (stub — clients call this on first connect)
1402// ---------------------------------------------------------------------------
1403
1404async fn get_music_folders(
1405    State(state): State<Arc<AppState>>,
1406    Query(params): Query<SubsonicParams>,
1407) -> Response {
1408    if let Err(e) = validate_auth(&params, &state) {
1409        return SubsonicResponse::error(params.wants_json(), &e);
1410    }
1411    let json = params.wants_json();
1412
1413    SubsonicResponse::ok(json)
1414        .child(
1415            XmlNode::new("musicFolders").child(
1416                XmlNode::new("musicFolder")
1417                    .attr("id", "1")
1418                    .attr("name", "Music"),
1419            ),
1420        )
1421        .build()
1422}
1423
1424// ---------------------------------------------------------------------------
1425// Endpoint: getGenres
1426// ---------------------------------------------------------------------------
1427
1428async fn get_genres(
1429    State(state): State<Arc<AppState>>,
1430    Query(params): Query<SubsonicParams>,
1431) -> Response {
1432    if let Err(e) = validate_auth(&params, &state) {
1433        return SubsonicResponse::error(params.wants_json(), &e);
1434    }
1435    let json = params.wants_json();
1436
1437    let db = match state.open_db() {
1438        Ok(db) => db,
1439        Err(e) => return SubsonicResponse::error(json, &e),
1440    };
1441
1442    let tracks = queries::all_tracks(&db.conn).unwrap_or_default();
1443    let mut genre_counts: std::collections::BTreeMap<String, usize> =
1444        std::collections::BTreeMap::new();
1445    for t in &tracks {
1446        if let Some(ref g) = t.genre {
1447            *genre_counts.entry(g.clone()).or_default() += 1;
1448        }
1449    }
1450
1451    let mut genres_node = XmlNode::new("genres").array_of("genre");
1452    for (name, count) in &genre_counts {
1453        genres_node = genres_node.child(
1454            XmlNode::new("genre")
1455                .attr("songCount", &count.to_string())
1456                .attr("albumCount", "0")
1457                .attr("value", name),
1458        );
1459    }
1460
1461    SubsonicResponse::ok(json).child(genres_node).build()
1462}
1463
1464// ---------------------------------------------------------------------------
1465// Playlist endpoints (mapped to koan snapshots)
1466// ---------------------------------------------------------------------------
1467
1468async fn get_playlists(
1469    State(state): State<Arc<AppState>>,
1470    Query(params): Query<SubsonicParams>,
1471) -> Response {
1472    if let Err(e) = validate_auth(&params, &state) {
1473        return SubsonicResponse::error(params.wants_json(), &e);
1474    }
1475    let json = params.wants_json();
1476
1477    let db = match state.open_db() {
1478        Ok(db) => db,
1479        Err(e) => return SubsonicResponse::error(json, &e),
1480    };
1481
1482    let snaps = queries::list_snapshots(&db.conn).unwrap_or_default();
1483
1484    let mut playlists_node = XmlNode::new("playlists").array_of("playlist");
1485    for snap in &snaps {
1486        playlists_node = playlists_node.child(
1487            XmlNode::new("playlist")
1488                .attr("id", &snap.name)
1489                .attr("name", &snap.name)
1490                .attr("songCount", &snap.track_count.to_string())
1491                .attr("owner", &state.username)
1492                .attr("public", "false")
1493                .attr("created", &snap.created_at),
1494        );
1495    }
1496
1497    SubsonicResponse::ok(json).child(playlists_node).build()
1498}
1499
1500#[derive(Debug, Deserialize)]
1501struct PlaylistIdParam {
1502    id: Option<String>,
1503    #[serde(flatten)]
1504    auth: SubsonicParams,
1505}
1506
1507async fn get_playlist(
1508    State(state): State<Arc<AppState>>,
1509    Query(params): Query<PlaylistIdParam>,
1510) -> Response {
1511    if let Err(e) = validate_auth(&params.auth, &state) {
1512        return SubsonicResponse::error(params.auth.wants_json(), &e);
1513    }
1514    let json = params.auth.wants_json();
1515
1516    let name = match params.id.as_deref() {
1517        Some(n) => n,
1518        None => return SubsonicResponse::error(json, &SubsonicError::missing_param("id")),
1519    };
1520
1521    let db = match state.open_db() {
1522        Ok(db) => db,
1523        Err(e) => return SubsonicResponse::error(json, &e),
1524    };
1525
1526    let snap = match queries::load_snapshot(&db.conn, name) {
1527        Ok(Some(s)) => s,
1528        _ => return SubsonicResponse::error(json, &SubsonicError::not_found("Playlist")),
1529    };
1530
1531    let mut playlist_node = XmlNode::new("playlist")
1532        .attr("id", &snap.name)
1533        .attr("name", &snap.name)
1534        .attr("songCount", &snap.items.len().to_string())
1535        .attr("owner", &state.username)
1536        .attr("public", "false")
1537        .attr("created", &snap.created_at)
1538        .array_of("entry");
1539
1540    // Resolve snapshot items to track entries.
1541    for item in &snap.items {
1542        if let Ok(Some(tid)) = queries::track_id_by_path(&db.conn, &item.path)
1543            && let Ok(Some(track)) = queries::get_track_row(&db.conn, tid)
1544        {
1545            playlist_node = playlist_node.child(track_to_xml_node(&track));
1546        }
1547    }
1548
1549    SubsonicResponse::ok(json).child(playlist_node).build()
1550}
1551
1552#[derive(Debug, Deserialize)]
1553struct CreatePlaylistParams {
1554    name: Option<String>,
1555    #[serde(rename = "playlistId")]
1556    playlist_id: Option<String>,
1557    #[serde(rename = "songId")]
1558    song_id: Option<Vec<String>>,
1559    #[serde(flatten)]
1560    auth: SubsonicParams,
1561}
1562
1563async fn create_playlist(
1564    State(state): State<Arc<AppState>>,
1565    Query(params): Query<CreatePlaylistParams>,
1566) -> Response {
1567    if let Err(e) = validate_auth(&params.auth, &state) {
1568        return SubsonicResponse::error(params.auth.wants_json(), &e);
1569    }
1570    let json = params.auth.wants_json();
1571
1572    let name = match params.name.as_deref().or(params.playlist_id.as_deref()) {
1573        Some(n) => n,
1574        None => return SubsonicResponse::error(json, &SubsonicError::missing_param("name")),
1575    };
1576
1577    let db = match state.open_db() {
1578        Ok(db) => db,
1579        Err(e) => return SubsonicResponse::error(json, &e),
1580    };
1581
1582    // Build persisted items from song IDs.
1583    let mut items = Vec::new();
1584    if let Some(ref ids) = params.song_id {
1585        for id_str in ids {
1586            if let Ok(tid) = id_str.parse::<i64>()
1587                && let Ok(Some(track)) = queries::get_track_row(&db.conn, tid)
1588            {
1589                let path = track
1590                    .path
1591                    .as_deref()
1592                    .or(track.cached_path.as_deref())
1593                    .unwrap_or("");
1594                items.push(koan_core::db::queries::playback_state::PersistedQueueItem {
1595                    path: path.to_string(),
1596                    title: track.title,
1597                    artist: track.artist_name,
1598                    album_artist: track.album_artist_name,
1599                    album: track.album_title,
1600                    year: None,
1601                    codec: track.codec,
1602                    track_number: track.track_number.map(|n| n as i64),
1603                    disc: track.disc.map(|n| n as i64),
1604                    duration_ms: track.duration_ms.map(|d| d as u64),
1605                    db_id: Some(tid),
1606                });
1607            }
1608        }
1609    }
1610
1611    if let Err(e) = queries::save_snapshot(&db.conn, name, &items, None, 0) {
1612        return SubsonicResponse::error(json, &SubsonicError::from(e.to_string()));
1613    }
1614
1615    SubsonicResponse::ok(json).build()
1616}
1617
1618async fn delete_playlist(
1619    State(state): State<Arc<AppState>>,
1620    Query(params): Query<PlaylistIdParam>,
1621) -> Response {
1622    if let Err(e) = validate_auth(&params.auth, &state) {
1623        return SubsonicResponse::error(params.auth.wants_json(), &e);
1624    }
1625    let json = params.auth.wants_json();
1626
1627    let name = match params.id.as_deref() {
1628        Some(n) => n,
1629        None => return SubsonicResponse::error(json, &SubsonicError::missing_param("id")),
1630    };
1631
1632    let db = match state.open_db() {
1633        Ok(db) => db,
1634        Err(e) => return SubsonicResponse::error(json, &e),
1635    };
1636
1637    match queries::delete_snapshot(&db.conn, name) {
1638        Ok(true) => SubsonicResponse::ok(json).build(),
1639        Ok(false) => SubsonicResponse::error(json, &SubsonicError::not_found("Playlist")),
1640        Err(e) => SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
1641    }
1642}
1643
1644// ===========================================================================
1645// Public router
1646// ===========================================================================
1647
1648/// Register all Subsonic REST routes on the given router.
1649fn register_subsonic_routes(router: axum::Router<Arc<AppState>>) -> axum::Router<Arc<AppState>> {
1650    router
1651        // Browsing
1652        .route("/rest/ping", get(ping))
1653        .route("/rest/ping.view", get(ping))
1654        .route("/rest/getLicense", get(get_license))
1655        .route("/rest/getLicense.view", get(get_license))
1656        .route("/rest/getArtists", get(get_artists))
1657        .route("/rest/getArtists.view", get(get_artists))
1658        .route("/rest/getArtist", get(get_artist))
1659        .route("/rest/getArtist.view", get(get_artist))
1660        .route("/rest/getAlbum", get(get_album))
1661        .route("/rest/getAlbum.view", get(get_album))
1662        .route("/rest/getAlbumList2", get(get_album_list2))
1663        .route("/rest/getAlbumList2.view", get(get_album_list2))
1664        .route("/rest/getSong", get(get_song))
1665        .route("/rest/getSong.view", get(get_song))
1666        // Search
1667        .route("/rest/search3", get(search3))
1668        .route("/rest/search3.view", get(search3))
1669        // Streaming + media
1670        .route("/rest/stream", get(stream))
1671        .route("/rest/stream.view", get(stream))
1672        .route("/rest/getCoverArt", get(get_cover_art))
1673        .route("/rest/getCoverArt.view", get(get_cover_art))
1674        // Interaction
1675        .route("/rest/star", get(star))
1676        .route("/rest/star.view", get(star))
1677        .route("/rest/unstar", get(unstar))
1678        .route("/rest/unstar.view", get(unstar))
1679        .route("/rest/getStarred2", get(get_starred2))
1680        .route("/rest/getStarred2.view", get(get_starred2))
1681        .route("/rest/scrobble", get(scrobble))
1682        .route("/rest/scrobble.view", get(scrobble))
1683        .route("/rest/getRandomSongs", get(get_random_songs))
1684        .route("/rest/getRandomSongs.view", get(get_random_songs))
1685        .route("/rest/getSimilarSongs2", get(get_similar_songs2))
1686        .route("/rest/getSimilarSongs2.view", get(get_similar_songs2))
1687        // Stubs + extras
1688        .route("/rest/getMusicFolders", get(get_music_folders))
1689        .route("/rest/getMusicFolders.view", get(get_music_folders))
1690        .route("/rest/getGenres", get(get_genres))
1691        .route("/rest/getGenres.view", get(get_genres))
1692        // Playlists (mapped to koan snapshots)
1693        .route("/rest/getPlaylists", get(get_playlists))
1694        .route("/rest/getPlaylists.view", get(get_playlists))
1695        .route("/rest/getPlaylist", get(get_playlist))
1696        .route("/rest/getPlaylist.view", get(get_playlist))
1697        .route("/rest/createPlaylist", get(create_playlist))
1698        .route("/rest/createPlaylist.view", get(create_playlist))
1699        .route("/rest/deletePlaylist", get(delete_playlist))
1700        .route("/rest/deletePlaylist.view", get(delete_playlist))
1701}
1702
1703/// Build a Subsonic-compatible REST API router.
1704///
1705/// Auth credentials come from `Config::load()`.  Returns `None` if remote
1706/// credentials are not configured (refuses to start with no auth).
1707pub fn subsonic_router(db_path: PathBuf) -> Option<axum::Router> {
1708    let cfg = Config::load().unwrap_or_default();
1709
1710    if cfg.remote.username.is_empty() {
1711        log::warn!(
1712            "Subsonic API disabled: remote.username is not configured. \
1713             Set remote credentials in config.toml to enable the Subsonic API."
1714        );
1715        return None;
1716    }
1717
1718    let password = koan_core::helpers::get_remote_password(&cfg).unwrap_or_default();
1719    if password.is_empty() {
1720        log::warn!(
1721            "Subsonic API disabled: remote password is empty. \
1722             Set remote credentials to enable the Subsonic API."
1723        );
1724        return None;
1725    }
1726
1727    let state = Arc::new(AppState {
1728        db_path,
1729        username: cfg.remote.username.clone(),
1730        password,
1731    });
1732
1733    Some(register_subsonic_routes(axum::Router::new()).with_state(state))
1734}
1735
1736// ===========================================================================
1737// Tests
1738// ===========================================================================
1739
1740#[cfg(test)]
1741mod tests {
1742    use super::*;
1743    use axum::body::Body;
1744    use axum::http::Request;
1745    use koan_core::db::queries::TrackMeta;
1746    use tower::ServiceExt;
1747
1748    fn test_state() -> (Arc<AppState>, tempfile::TempDir) {
1749        let dir = tempfile::tempdir().unwrap();
1750        let db_path = dir.path().join("test.db");
1751        let db = Database::open(&db_path).unwrap();
1752        koan_core::db::schema::create_tables(&db.conn).unwrap();
1753
1754        let state = Arc::new(AppState {
1755            db_path,
1756            username: "testuser".into(),
1757            password: "testpass".into(),
1758        });
1759        (state, dir)
1760    }
1761
1762    fn build_test_router(state: Arc<AppState>) -> axum::Router {
1763        register_subsonic_routes(axum::Router::new()).with_state(state)
1764    }
1765
1766    fn auth_query(extra: &str) -> String {
1767        let salt = "abc123";
1768        let token = format!("{:x}", md5::compute(format!("testpass{}", salt)));
1769        let base = format!("u=testuser&t={}&s={}&v=1.16.1&c=test", token, salt);
1770        if extra.is_empty() {
1771            base
1772        } else {
1773            format!("{}&{}", base, extra)
1774        }
1775    }
1776
1777    fn seed_data(state: &AppState) {
1778        let db = Database::open(&state.db_path).unwrap();
1779        let meta = TrackMeta {
1780            title: "Test Song".into(),
1781            artist: "Test Artist".into(),
1782            album: "Test Album".into(),
1783            album_artist: Some("Test Artist".into()),
1784            track_number: Some(1),
1785            disc: Some(1),
1786            duration_ms: Some(240_000),
1787            codec: Some("FLAC".into()),
1788            sample_rate: Some(44100),
1789            bit_depth: Some(16),
1790            channels: Some(2),
1791            bitrate: Some(1411),
1792            genre: Some("Rock".into()),
1793            path: Some("/music/test.flac".into()),
1794            date: Some("2020".into()),
1795            label: None,
1796            size_bytes: None,
1797            mtime: None,
1798            source: "local".into(),
1799            remote_id: None,
1800            remote_url: None,
1801        };
1802        queries::upsert_track(&db.conn, &meta).unwrap();
1803    }
1804
1805    async fn get_response(app: axum::Router, uri: &str) -> (StatusCode, String) {
1806        let resp = app
1807            .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1808            .await
1809            .unwrap();
1810        let status = resp.status();
1811        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1812            .await
1813            .unwrap();
1814        (status, String::from_utf8(body.to_vec()).unwrap())
1815    }
1816
1817    // --- Unit tests ---
1818
1819    #[test]
1820    fn test_xml_escape() {
1821        assert_eq!(xml_escape("A&B"), "A&amp;B");
1822        assert_eq!(xml_escape("a<b>c"), "a&lt;b&gt;c");
1823        assert_eq!(xml_escape(r#"say "hi""#), "say &quot;hi&quot;");
1824    }
1825
1826    #[test]
1827    fn test_codec_to_mime() {
1828        assert_eq!(codec_to_mime("FLAC"), ("flac", "audio/flac"));
1829        assert_eq!(codec_to_mime("MP3"), ("mp3", "audio/mpeg"));
1830        assert_eq!(codec_to_mime("AAC"), ("m4a", "audio/mp4"));
1831        assert_eq!(codec_to_mime("Opus"), ("opus", "audio/opus"));
1832    }
1833
1834    #[test]
1835    fn test_extension_to_mime() {
1836        assert_eq!(extension_to_mime("flac"), "audio/flac");
1837        assert_eq!(extension_to_mime("mp3"), "audio/mpeg");
1838        assert_eq!(extension_to_mime("m4a"), "audio/mp4");
1839        assert_eq!(extension_to_mime("FLAC"), "audio/flac");
1840    }
1841
1842    #[test]
1843    fn test_hex_decode() {
1844        assert_eq!(hex_decode("68656c6c6f"), "hello");
1845        assert_eq!(hex_decode(""), "");
1846    }
1847
1848    #[test]
1849    fn test_parse_range_full() {
1850        assert_eq!(parse_range("bytes=0-999", 5000), Some((0, 999)));
1851    }
1852
1853    #[test]
1854    fn test_parse_range_open_end() {
1855        assert_eq!(parse_range("bytes=1000-", 5000), Some((1000, 4999)));
1856    }
1857
1858    #[test]
1859    fn test_parse_range_suffix() {
1860        assert_eq!(parse_range("bytes=-500", 5000), Some((4500, 4999)));
1861    }
1862
1863    #[test]
1864    fn test_parse_range_out_of_bounds() {
1865        assert_eq!(parse_range("bytes=5000-6000", 5000), None);
1866    }
1867
1868    #[test]
1869    fn test_parse_range_clamps_end() {
1870        assert_eq!(parse_range("bytes=4000-9999", 5000), Some((4000, 4999)));
1871    }
1872
1873    // --- Integration tests ---
1874
1875    #[tokio::test]
1876    async fn test_ping_ok() {
1877        let (state, _dir) = test_state();
1878        let app = build_test_router(state);
1879        let (status, body) = get_response(app, &format!("/rest/ping?{}", auth_query(""))).await;
1880        assert_eq!(status, StatusCode::OK);
1881        assert!(body.contains("status=\"ok\""));
1882    }
1883
1884    #[tokio::test]
1885    async fn test_ping_json() {
1886        let (state, _dir) = test_state();
1887        let app = build_test_router(state);
1888        let (status, body) =
1889            get_response(app, &format!("/rest/ping?{}", auth_query("f=json"))).await;
1890        assert_eq!(status, StatusCode::OK);
1891        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
1892        assert_eq!(parsed["subsonic-response"]["status"], "ok");
1893    }
1894
1895    #[tokio::test]
1896    async fn test_ping_wrong_password() {
1897        let (state, _dir) = test_state();
1898        let app = build_test_router(state);
1899        let (status, body) = get_response(
1900            app,
1901            "/rest/ping?u=testuser&t=wrongtoken&s=abc&v=1.16.1&c=test",
1902        )
1903        .await;
1904        assert_eq!(status, StatusCode::OK);
1905        assert!(body.contains("status=\"failed\""));
1906        assert!(body.contains("code=\"40\""));
1907    }
1908
1909    #[tokio::test]
1910    async fn test_ping_wrong_username() {
1911        let (state, _dir) = test_state();
1912        let app = build_test_router(state);
1913        let salt = "abc123";
1914        let token = format!("{:x}", md5::compute(format!("testpass{}", salt)));
1915        let (_, body) = get_response(
1916            app,
1917            &format!(
1918                "/rest/ping?u=wronguser&t={}&s={}&v=1.16.1&c=test",
1919                token, salt
1920            ),
1921        )
1922        .await;
1923        assert!(body.contains("status=\"failed\""));
1924        assert!(body.contains("code=\"40\""));
1925    }
1926
1927    #[tokio::test]
1928    async fn test_legacy_password_auth() {
1929        let (state, _dir) = test_state();
1930        let app = build_test_router(state);
1931        let (_, body) = get_response(app, "/rest/ping?u=testuser&p=testpass&v=1.16.1&c=test").await;
1932        assert!(body.contains("status=\"ok\""));
1933    }
1934
1935    #[tokio::test]
1936    async fn test_get_license() {
1937        let (state, _dir) = test_state();
1938        let app = build_test_router(state);
1939        let (_, body) = get_response(app, &format!("/rest/getLicense?{}", auth_query(""))).await;
1940        assert!(body.contains("license"));
1941        assert!(body.contains("valid=\"true\""));
1942    }
1943
1944    #[tokio::test]
1945    async fn test_get_artists_empty() {
1946        let (state, _dir) = test_state();
1947        let app = build_test_router(state);
1948        let (_, body) = get_response(app, &format!("/rest/getArtists?{}", auth_query(""))).await;
1949        assert!(body.contains("status=\"ok\""));
1950        assert!(body.contains("<artists"));
1951    }
1952
1953    #[tokio::test]
1954    async fn test_get_artists_with_data() {
1955        let (state, _dir) = test_state();
1956        seed_data(&state);
1957        let app = build_test_router(state);
1958        let (_, body) = get_response(app, &format!("/rest/getArtists?{}", auth_query(""))).await;
1959        assert!(body.contains("Test Artist"));
1960    }
1961
1962    #[tokio::test]
1963    async fn test_get_artist_by_id() {
1964        let (state, _dir) = test_state();
1965        seed_data(&state);
1966
1967        let db = Database::open(&state.db_path).unwrap();
1968        let artists = queries::all_artists(&db.conn).unwrap();
1969        let artist = &artists[0];
1970
1971        let app = build_test_router(state);
1972        let (_, body) = get_response(
1973            app,
1974            &format!("/rest/getArtist?{}&id={}", auth_query(""), artist.id),
1975        )
1976        .await;
1977        assert!(body.contains("Test Artist"));
1978        assert!(body.contains("Test Album"));
1979    }
1980
1981    #[tokio::test]
1982    async fn test_get_album_by_id() {
1983        let (state, _dir) = test_state();
1984        seed_data(&state);
1985
1986        let db = Database::open(&state.db_path).unwrap();
1987        let albums = queries::all_albums(&db.conn).unwrap();
1988        let album = &albums[0];
1989
1990        let app = build_test_router(state);
1991        let (_, body) = get_response(
1992            app,
1993            &format!("/rest/getAlbum?{}&id={}", auth_query(""), album.id),
1994        )
1995        .await;
1996        assert!(body.contains("Test Album"));
1997        assert!(body.contains("Test Song"));
1998    }
1999
2000    #[tokio::test]
2001    async fn test_get_song_by_id() {
2002        let (state, _dir) = test_state();
2003        seed_data(&state);
2004
2005        let db = Database::open(&state.db_path).unwrap();
2006        let albums = queries::all_albums(&db.conn).unwrap();
2007        let tracks = queries::tracks_for_album(&db.conn, albums[0].id).unwrap();
2008        let track = &tracks[0];
2009
2010        let app = build_test_router(state);
2011        let (_, body) = get_response(
2012            app,
2013            &format!("/rest/getSong?{}&id={}", auth_query(""), track.id),
2014        )
2015        .await;
2016        assert!(body.contains("Test Song"));
2017        assert!(body.contains("Test Artist"));
2018    }
2019
2020    #[tokio::test]
2021    async fn test_get_album_list2() {
2022        let (state, _dir) = test_state();
2023        seed_data(&state);
2024
2025        let app = build_test_router(state);
2026        let (_, body) = get_response(
2027            app,
2028            &format!(
2029                "/rest/getAlbumList2?{}&type=alphabeticalByName&size=10",
2030                auth_query("")
2031            ),
2032        )
2033        .await;
2034        assert!(body.contains("Test Album"));
2035    }
2036
2037    #[tokio::test]
2038    async fn test_get_song_not_found() {
2039        let (state, _dir) = test_state();
2040        let app = build_test_router(state);
2041        let (_, body) =
2042            get_response(app, &format!("/rest/getSong?{}&id=99999", auth_query(""))).await;
2043        assert!(body.contains("status=\"failed\""));
2044        assert!(body.contains("code=\"70\""));
2045    }
2046
2047    #[tokio::test]
2048    async fn test_json_response_format() {
2049        let (state, _dir) = test_state();
2050        seed_data(&state);
2051
2052        let db = Database::open(&state.db_path).unwrap();
2053        let albums = queries::all_albums(&db.conn).unwrap();
2054
2055        let app = build_test_router(state);
2056        let (_, body) = get_response(
2057            app,
2058            &format!(
2059                "/rest/getAlbum?{}&id={}",
2060                auth_query("f=json"),
2061                albums[0].id
2062            ),
2063        )
2064        .await;
2065
2066        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
2067        assert_eq!(parsed["subsonic-response"]["status"], "ok");
2068        assert!(parsed["subsonic-response"]["album"].is_object());
2069    }
2070
2071    #[tokio::test]
2072    async fn test_view_suffix_routes() {
2073        let (state, _dir) = test_state();
2074        let app = build_test_router(state);
2075        let (status, body) =
2076            get_response(app, &format!("/rest/ping.view?{}", auth_query(""))).await;
2077        assert_eq!(status, StatusCode::OK);
2078        assert!(body.contains("status=\"ok\""));
2079    }
2080
2081    // --- New endpoint tests ---
2082
2083    #[tokio::test]
2084    async fn test_get_music_folders() {
2085        let (state, _dir) = test_state();
2086        let app = build_test_router(state);
2087        let (_, body) =
2088            get_response(app, &format!("/rest/getMusicFolders?{}", auth_query(""))).await;
2089        assert!(body.contains("musicFolder"));
2090        assert!(body.contains("Music"));
2091    }
2092
2093    #[tokio::test]
2094    async fn test_get_genres() {
2095        let (state, _dir) = test_state();
2096        seed_data(&state);
2097        let app = build_test_router(state);
2098        let (_, body) = get_response(app, &format!("/rest/getGenres?{}", auth_query(""))).await;
2099        assert!(body.contains("Rock"));
2100    }
2101
2102    #[tokio::test]
2103    async fn test_search3() {
2104        let (state, _dir) = test_state();
2105        seed_data(&state);
2106        let app = build_test_router(state);
2107        let (_, body) =
2108            get_response(app, &format!("/rest/search3?{}&query=Test", auth_query(""))).await;
2109        assert!(body.contains("Test Song"));
2110        assert!(body.contains("Test Artist"));
2111    }
2112
2113    #[tokio::test]
2114    async fn test_star_and_get_starred() {
2115        let (state, _dir) = test_state();
2116        seed_data(&state);
2117
2118        let db = Database::open(&state.db_path).unwrap();
2119        let tracks = queries::all_tracks(&db.conn).unwrap();
2120        let track_id = tracks[0].id;
2121
2122        let app = build_test_router(state.clone());
2123        let (_, body) = get_response(
2124            app,
2125            &format!("/rest/star?{}&id={}", auth_query(""), track_id),
2126        )
2127        .await;
2128        assert!(body.contains("status=\"ok\""));
2129
2130        let app = build_test_router(state);
2131        let (_, body) = get_response(app, &format!("/rest/getStarred2?{}", auth_query(""))).await;
2132        assert!(body.contains("Test Song"));
2133    }
2134
2135    #[tokio::test]
2136    async fn test_playlists_crud() {
2137        let (state, _dir) = test_state();
2138        seed_data(&state);
2139
2140        // Create (empty playlist first — songId[] parsing needs special handling)
2141        let app = build_test_router(state.clone());
2142        let (_, body) = get_response(
2143            app,
2144            &format!("/rest/createPlaylist?{}&name=testmix", auth_query("")),
2145        )
2146        .await;
2147        assert!(
2148            body.contains("status=\"ok\""),
2149            "createPlaylist failed: {}",
2150            body
2151        );
2152
2153        // List
2154        let app = build_test_router(state.clone());
2155        let (_, body) = get_response(app, &format!("/rest/getPlaylists?{}", auth_query(""))).await;
2156        assert!(body.contains("testmix"));
2157
2158        // Get
2159        let app = build_test_router(state.clone());
2160        let (_, body) = get_response(
2161            app,
2162            &format!("/rest/getPlaylist?{}&id=testmix", auth_query("")),
2163        )
2164        .await;
2165        assert!(body.contains("testmix"));
2166
2167        // Delete
2168        let app = build_test_router(state.clone());
2169        let (_, body) = get_response(
2170            app,
2171            &format!("/rest/deletePlaylist?{}&id=testmix", auth_query("")),
2172        )
2173        .await;
2174        assert!(body.contains("status=\"ok\""));
2175
2176        // Verify deleted
2177        let app = build_test_router(state);
2178        let (_, body) = get_response(
2179            app,
2180            &format!("/rest/getPlaylist?{}&id=testmix", auth_query("")),
2181        )
2182        .await;
2183        assert!(body.contains("status=\"failed\""));
2184    }
2185
2186    #[tokio::test]
2187    async fn test_scrobble() {
2188        let (state, _dir) = test_state();
2189        seed_data(&state);
2190
2191        let db = Database::open(&state.db_path).unwrap();
2192        let tracks = queries::all_tracks(&db.conn).unwrap();
2193
2194        let app = build_test_router(state);
2195        let (_, body) = get_response(
2196            app,
2197            &format!("/rest/scrobble?{}&id={}", auth_query(""), tracks[0].id),
2198        )
2199        .await;
2200        assert!(body.contains("status=\"ok\""));
2201    }
2202
2203    #[tokio::test]
2204    async fn test_get_random_songs() {
2205        let (state, _dir) = test_state();
2206        seed_data(&state);
2207        let app = build_test_router(state);
2208        let (_, body) = get_response(
2209            app,
2210            &format!("/rest/getRandomSongs?{}&size=5", auth_query("")),
2211        )
2212        .await;
2213        assert!(body.contains("randomSongs"));
2214    }
2215}